From 713d7275656e79568ba923b84f9725170d63b312 Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 16:57:34 +0800 Subject: [PATCH 01/13] feat(UI): Modify the Sidebar, make the message more clear --- web/styles.css | 201 ++++++++++++++++++++++++++++++------------------- 1 file changed, 124 insertions(+), 77 deletions(-) diff --git a/web/styles.css b/web/styles.css index a1c6cf2..dc03486 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,86 +1,101 @@ +:root { + --surface-background: var(--vscode-sideBar-background, #f5f5f5); + --surface-card: var(--vscode-editorWidget-background, #ffffff); + --surface-muted: var(--vscode-editor-inactiveSelectionBackground, rgba(0, 0, 0, 0.05)); + --border-color: var(--vscode-editorWidget-border, rgba(0, 0, 0, 0.08)); + --assistant-accent: var(--vscode-testing-iconPassed, #2ea043); + --user-accent: var(--vscode-testing-iconQueued, #007acc); +} + html { overflow-y: auto; } body { - /* font-family: Arial, sans-serif; */ margin: 0; - padding: 0; + padding: 24px 16px 32px; display: flex; flex-direction: column; - height: 100vh; + min-height: 100vh; overflow-y: auto; + background-color: var(--surface-background); + color: var(--vscode-sideBar-foreground, #1e1e1e); + font-family: var(--vscode-font-family, "Segoe UI", "Helvetica Neue", Arial, sans-serif); + gap: 16px; + box-sizing: border-box; +} + +#no-message, +#chat-container { + width: min(960px, 100%); + align-self: center; } #chat-container { flex: 1; display: flex; flex-direction: column; - /* border: 1px solid #ccc; */ - padding: 10px; - /* overflow-y: auto; */ - /* transition: all 0.3s ease; */ + gap: 12px; + padding: 20px; + background-color: var(--surface-card); + border-radius: 16px; + border: 1px solid var(--border-color); + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.08); } -#input-container { - display: flex; - border-top: 1px solid #ccc; - padding: 10px; +#no-message { + background-color: var(--surface-card); + border: 1px dashed var(--border-color); + border-radius: 16px; + padding: 32px; + text-align: center; + box-shadow: inset 0 0 0 1px var(--surface-muted); } -#input-container input { - flex: 1; - padding: 10px; - border: 1px solid #ccc; - border-radius: 4px; - width: 50px; +#no-message h1 { + margin-bottom: 0.3em; } -#input-container button { +#input-container { + display: flex; + border-top: 1px solid #ccc; padding: 10px; - margin-left: 10px; - border: 1px solid #ccc; - border-radius: 4px; - background-color: #007acc; - color: white; - cursor: pointer; } .message { - /* height: 30px; */ - margin: 5px 0 20px 0; - border-radius: 4px; - transition: all 0.5s ease-in-out; + margin: 0 0 24px 0; + border-radius: 12px; + transition: all 0.25s ease-in-out; overflow: hidden; max-width: 100%; + background-color: var(--surface-card); + border: 1px solid var(--border-color); + padding: 12px 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); } + .message-content { - /* white-space: pre-wrap; /* this is to render linebreak in pure text, not needed anymore when using a piece of html parsed from markdown */ - padding: 0 10px; - transition: all 0.5s ease-in-out; - /* overflow-x: auto; /* this will hiden the overflowing button */ + padding: 0; + transition: all 0.25s ease-in-out; + color: inherit; } - .message.assistant { - background-color: #f1f1f1; + border-left: 4px solid var(--assistant-accent); + background-image: linear-gradient(135deg, rgba(46, 160, 67, 0.08), transparent); margin-right: auto; - /* align-self: flex-start; */ } .message.user, .message.system { - /* background-color: #007acc; - color: white; */ - background-color: #f1f1f1; - /* margin-left: auto; */ + background-image: linear-gradient(135deg, rgba(0, 122, 204, 0.08), transparent); + border-left: 4px solid var(--user-accent); margin-right: auto; - /* align-self: flex-end; */ } .message.bot { - background-color: #f1f1f1; - align-self: flex-start; /* this will break overflow, see */ + background-color: var(--surface-card); + align-self: flex-start; } .message.bot.title { @@ -99,50 +114,66 @@ body { } .message h1 { - font-size: 1.2em; - margin-block-start: 0.5em; - margin-block-end: 0.5em; - margin-inline-start: 0em; - margin-inline-end: 0em; + font-size: 1.25em; + margin: 0.5em 0; } -.message p code, -.message li code { - line-height: 2em; +.message p { + white-space: pre-wrap; + line-height: 1.6; + margin: 0.35em 0; } -.message pre { - position: relative; /* for the button to float */ +.message ul, +.message ol { + padding-left: 1.2rem; +} + +.message li { + margin: 0.2em 0; } .message code { - background-color: var(--vscode-editor-background); + background-color: var(--surface-muted); font-family: var(--vscode-editor-font-family); + border-radius: 6px; + padding: 0.1em 0.4em; + color: inherit; } -.message p { - white-space: pre-wrap; +.message p code, +.message li code { + line-height: 1.8em; } -.message li { - margin-block-start: 0.3em; - margin-block-end: 0.3em; +.message pre { + position: relative; + background-color: var(--vscode-editor-background, #1e1e1e); + color: var(--vscode-editor-foreground, #d4d4d4); + border-radius: 12px; + padding: 16px 48px 16px 16px; + margin: 0.6em 0; + overflow-x: auto; + border: 1px solid var(--border-color); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); } .message-header { - font-size: large; - font-weight: bold; - /* display: flex; - justify-content: space-between; - align-items: center; - padding: 10px; - background-color: #f1f1f1; - border-bottom: 1px solid #ccc; */ + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--vscode-descriptionForeground, rgba(0, 0, 0, 0.55)); + margin: 4px 0 6px; } .typing { display: flex; align-items: center; + gap: 6px; + background-color: var(--surface-muted); + padding: 12px; + border-radius: 8px; } .typing span { @@ -163,27 +194,43 @@ body { animation-delay: 0.4s; } -.open-code-button { +.open-code-button, +.restart-from-here-button { position: absolute; - right: 10px; - top: -10px; - /* transform: translateY(-50%); */ - width: 24px; - height: 24px; + top: 8px; + width: 28px; + height: 28px; padding: 2px; - opacity: 0.3; + opacity: 0.5; visibility: hidden; + border-radius: 6px; + border: 1px solid var(--border-color); + background-color: rgba(0, 0, 0, 0.04); + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; } -.open-code-button.show { +.open-code-button { + right: 8px; +} + +.restart-from-here-button { + right: 44px; +} + +.open-code-button.show, +.restart-from-here-button.show { visibility: visible; } -.open-code-button.show:hover { +.open-code-button.show:hover, +.restart-from-here-button.show:hover { opacity: 1.0; + background-color: rgba(0, 0, 0, 0.08); } - @keyframes blink { 0%, @@ -198,5 +245,5 @@ body { } #no-message { - padding: 0 2em; + padding: 0 2em; } From 7578433fff801d7acb9019920cea2a17cdd39985 Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 16:59:06 +0800 Subject: [PATCH 02/13] feat(UI): Reconstruct the index.html and index.js, delete repeat load --- web/index.html | 155 +------------------ web/index.js | 395 ++++++++++++++++++++++--------------------------- 2 files changed, 174 insertions(+), 376 deletions(-) diff --git a/web/index.html b/web/index.html index 9ce6f10..d013240 100644 --- a/web/index.html +++ b/web/index.html @@ -16,161 +16,8 @@

Intention Test 🧪

An LLM-based iterative test generator.
Waiting for request...
- - \ No newline at end of file + diff --git a/web/index.js b/web/index.js index 8724478..f990c48 100644 --- a/web/index.js +++ b/web/index.js @@ -1,279 +1,230 @@ const noMessagePrompt = document.getElementById('no-message'); const chatContainer = document.getElementById('chat-container'); -const userInput = document.getElementById('user-input'); -const body = document.getElementsByTagName('body')[0]; +const body = document.body; -body.addEventListener('keydown', function (event) { - if (event.key === 'Enter') { - sendMessage(); - } -}); +const OPEN_CODE_ICON = ''; +const RESTART_ICON = ''; + +let messageCount = 0; +let lastUserScrollTime = Date.now(); -function timeout(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); +const canConnectToVsCode = typeof acquireVsCodeApi === 'function'; +if (canConnectToVsCode) { + window.vscode = acquireVsCodeApi(); } -function onClickForMessage(msg) { - return () => { - if (msg.classList.contains('expand')) { - msg.classList.remove('expand'); - msg.style.maxHeight = "5px"; - } else { - msg.classList.add('expand'); - msg.style.maxHeight = msg.scrollHeight + 'px'; - } +body.addEventListener('wheel', () => { + lastUserScrollTime = Date.now(); +}, { passive: true }); + +body.addEventListener('mousedown', () => { + lastUserScrollTime = Date.now(); +}); + +function maybeAutoScroll() { + if (Date.now() - lastUserScrollTime > 3000) { + body.scrollTo({ + top: body.scrollHeight, + behavior: 'smooth' + }); } } function createMessageContent(message, isHtml) { const messageContentElement = document.createElement('div'); - messageContentElement.className = 'message-content' + messageContentElement.className = 'message-content'; if (isHtml) { messageContentElement.innerHTML = message; - } - else { + } else { messageContentElement.textContent = message; } - - let toInput = false; - let originalHtml = ''; - - let switchToOk = () => { - if (toInput) { - const newRaw = messageContentElement.querySelector('textarea').value; - const newRawHtml = marked.parse(newRaw); - messageContentElement.innerHTML = newRawHtml; - messageContentElement.querySelectorAll('pre code').forEach((block) => { - hljs.highlightElement(block); - }); - messageContentElement.querySelectorAll('code').forEach((block) => { - const buttonSvg = '' - const button = document.createElement('button'); - button.className = 'open-code-button'; - button.attributes.title = 'Open'; - button.onclick = (e) => { - let lang; - for (const cls of block.classList) { - if (cls.startsWith('language-')) { - lang = cls.substring('language-'.length); - break; - } - } - e.stopPropagation(); - window.vscode?.postMessage({ cmd: 'open-code', content: block.textContent, lang: lang }); - }; - button.innerHTML = buttonSvg; - block.appendChild(button); - - block.addEventListener('mousemove',() => { button.classList.add('show'); }); - block.addEventListener('mouseleave',() => { - button.classList.remove('show'); - }); - { - const buttonSvg = ''; - const restartButton = document.createElement('button'); - restartButton.className = 'restart-from-here-button'; - restartButton.attributes.title = 'Restart with this'; - restartButton.onclick = (e) => { - e.stopPropagation(); - window.vscode?.postMessage({ cmd: 'restart-session', number: messageContentElement.parentElement.index }); - }; - restartButton.innerHTML = buttonSvg; - block.appendChild(restartButton); - - block.onmousemove = () => { restartButton.classList.add('show'); }; - block.onmouseleave = () => { - restartButton.classList.remove('show'); - }; - } - }); - toInput = false; - } - }; - let switchToModify = () => { - if (!toInput) { - // originalHtml = messageContentElement.innerHTML; - const modifyInput = document.createElement('textarea'); - modifyInput.cols = 100; - modifyInput.rows = 8; - modifyInput.style.display = 'block'; - modifyInput.style.width = '100%'; - modifyInput.style.margin = '0.5em 0'; - modifyInput.style.padding = '0.3em'; - modifyInput.style.resize = 'none'; - modifyInput.style.overflow = 'auto'; - modifyInput.style.boxSizing = 'border-box'; - modifyInput.value = raw; - const okButton = document.createElement('button'); - okButton.textContent = 'Submit'; - okButton.style.margin = '0 0 0.5em 0'; - okButton.onclick = (e) => { - switchToOk(); - e?.stopPropagation(); - }; - messageContentElement.innerHTML = ''; - messageContentElement.appendChild(modifyInput); - messageContentElement.appendChild(okButton); - toInput = !toInput; - } - }; - messageContentElement.switchToOk = switchToOk; - - - messageContentElement.addEventListener('click', () => { - if (!toInput) { - // const scrollTop = messageContentElement.scrollTop; - liveContentContainers?.switchToOk(); - liveContentContainers = messageContentElement; - - switchToModify(); - - messageContentElement.scrollIntoView({ block: 'center' }); - // messageContentElement.scrollTop = scrollTop; - - toInput = true; - } - }); return messageContentElement; } -let total_messages = 0; +function capitalize(label) { + if (!label) { + return ''; + } + return label[0].toUpperCase() + label.substring(1); +} + +function addMessage(message, sender, options = {}) { + const { + raw = '', + isHtml = false, + senderType, + extraClasses = [], + enhance = true + } = options; -function addMessage(message, raw, sender, isHtml, senderType) { - const messageContentElement = createMessageContent(message, raw, isHtml); + const messageContentElement = createMessageContent(message, isHtml); const messageElement = document.createElement('div'); messageElement.appendChild(messageContentElement); - messageElement.className = 'message before-show ' + sender; + messageElement.dataset.raw = raw ?? ''; + + const classNames = ['message', sender, ...extraClasses]; + messageElement.className = classNames.filter(Boolean).join(' '); const messageHeader = document.createElement('div'); messageHeader.className = 'message-header'; - if (!senderType) { - senderType = sender; - } - messageHeader.textContent = senderType[0].toUpperCase() + senderType.substring(1); + const label = senderType ?? sender; + messageHeader.textContent = capitalize(label); chatContainer.appendChild(messageHeader); chatContainer.appendChild(messageElement); - // doScroll(); - messageElement.index = total_messages; + messageElement.index = messageCount; + + if (enhance) { + enhanceMessageElement(messageElement); + } + + maybeAutoScroll(); return messageElement; } -window.lastMouseTime = Date.now(); -body.addEventListener('mousewheel', function (e) { - window.lastMouseTime = Date.now(); -}); -body.addEventListener('mousedown', function (e) { - window.lastMouseTime = Date.now(); -}); -function doScroll() { - if (!(window.lastMouseTime && Date.now() - window.lastMouseTime < 3000)) { - body.scrollTo({ - top: body.scrollHeight, - behavior: 'smooth' - }); +function enhanceMessageElement(messageElement) { + const codeBlocks = messageElement.querySelectorAll('pre code'); + codeBlocks.forEach((block) => { + block.querySelectorAll('.open-code-button, .restart-from-here-button').forEach((btn) => btn.remove()); + hljs.highlightElement(block); + attachCodeBlockActions(block, messageElement); + }); + + messageElement.querySelectorAll('code').forEach((inlineCode) => { + if (inlineCode.parentElement?.tagName.toLowerCase() !== 'pre') { + hljs.highlightElement(inlineCode); + } + }); +} + +function attachCodeBlockActions(block, messageElement) { + const openButton = createIconButton('open-code-button', 'Open', OPEN_CODE_ICON); + openButton.onclick = (event) => { + event.stopPropagation(); + const lang = detectLanguage(block); + window.vscode?.postMessage({ cmd: 'open-code', content: block.textContent, lang }); + }; + + const restartButton = createIconButton('restart-from-here-button', 'Restart with this', RESTART_ICON); + restartButton.onclick = (event) => { + event.stopPropagation(); + if (typeof messageElement.index === 'number') { + window.vscode?.postMessage({ cmd: 'restart-session', number: messageElement.index }); + } + }; + + block.appendChild(openButton); + block.appendChild(restartButton); + + block.onmouseenter = () => toggleActionButtons(block, true); + block.onmouseleave = () => toggleActionButtons(block, false); +} + +function createIconButton(className, title, icon) { + const button = document.createElement('button'); + button.className = className; + button.title = title; + button.innerHTML = icon; + return button; +} + +function toggleActionButtons(block, visible) { + block.querySelectorAll('.open-code-button, .restart-from-here-button').forEach((button) => { + button.classList.toggle('show', visible); + }); +} + +function detectLanguage(block) { + for (const cls of block.classList) { + if (cls.startsWith('language-')) { + return cls.substring('language-'.length); + } } + return undefined; } function showTypingAnimation(sender) { - addMessage('
', 'typing ' + sender, true, sender); + addMessage('
', sender, { + isHtml: true, + senderType: sender, + extraClasses: ['typing'], + enhance: false + }); } -function completeTypingAnimation(html, sender) { - const typingElement = document.querySelector('.typing'); - if (typingElement) { - typingElement.className = 'message ' + sender; - typingElement.innerHTML = ''; - typingElement.appendChild(createMessageContent(html, raw, true)); - // doScroll(); - typingElement.index = total_messages; - return typingElement; +function completeTypingAnimation(message, sender, options = {}) { + const typingElement = document.querySelector('.message.typing'); + if (!typingElement) { + return undefined; } - return undefined; + + typingElement.className = ['message', sender].join(' ').trim(); + typingElement.dataset.raw = options.raw ?? ''; + typingElement.innerHTML = ''; + + const messageContentElement = createMessageContent(message, options.isHtml ?? false); + typingElement.appendChild(messageContentElement); + + if (options.enhance !== false) { + enhanceMessageElement(typingElement); + } + + typingElement.index = messageCount; + maybeAutoScroll(); + return typingElement; } function removeTypingAnimation() { - const typingElement = document.querySelector('.typing'); + const typingElement = document.querySelector('.message.typing'); if (typingElement) { + const header = typingElement.previousSibling; + if (header && header.classList?.contains('message-header')) { + chatContainer.removeChild(header); + } chatContainer.removeChild(typingElement); } } -const canConnectToVsCode = (window.acquireVsCodeApi !== undefined); -if (canConnectToVsCode) { - window.vscode = acquireVsCodeApi(); -} - -window.addEventListener('message', async (event) => { - console.log(event.data); - msg = event.data; - if (msg.role && msg.content) { +window.addEventListener('message', (event) => { + const msg = event.data; + if (msg?.role && msg?.content) { noMessagePrompt.style.display = 'none'; - if (msg.role.endsWith('-wait')) { - msg.role = msg.role.substring(0, msg.role.length - '-wait'.length); - showTypingAnimation(msg.role); - } else { - let messageElement = completeTypingAnimation(msg.content, msg.raw, msg.role) - ?? addMessage(msg.content, msg.raw, msg.role, true); - total_messages = total_messages + 1; - messageElement.querySelectorAll('pre code').forEach((block) => { - hljs.highlightElement(block); - }); - messageElement.querySelectorAll('code').forEach((block) => { - const buttonSvg = '' - const button = document.createElement('button'); - button.className = 'open-code-button'; - button.attributes.title = 'Open'; - button.onclick = (e) => { - let lang; - for (const cls of block.classList) { - if (cls.startsWith('language-')) { - lang = cls.substring('language-'.length); - break; - } - } - e.stopPropagation(); - window.vscode?.postMessage({ cmd: 'open-code', content: block.textContent, lang: lang }); - } - button.innerHTML = buttonSvg; - block.appendChild(button); - - block.addEventListener('mousemove',() => { button.classList.add('show'); }); - block.addEventListener('mouseleave',() => { - button.classList.remove('show'); - }); - { - const buttonSvg = ''; - const restartButton = document.createElement('button'); - restartButton.className = 'restart-from-here-button'; - restartButton.attributes.title = 'Restart with this'; - restartButton.onclick = (e) => { - e.stopPropagation(); - window.vscode?.postMessage({ cmd: 'restart-session', number: messageElement.index }); - }; - restartButton.innerHTML = buttonSvg; - block.appendChild(restartButton); - - block.onmousemove = () => { restartButton.classList.add('show'); }; - block.onmouseleave = () => { - restartButton.classList.remove('show'); - }; - } + const waitSuffix = '-wait'; + const isWaiting = msg.role.endsWith(waitSuffix); + const senderRole = isWaiting ? msg.role.slice(0, -waitSuffix.length) : msg.role; + + if (isWaiting) { + showTypingAnimation(senderRole); + return; + } + + const rawContent = typeof msg.raw === 'string' ? msg.raw : msg.content; + const messageElement = + completeTypingAnimation(msg.content, senderRole, { raw: rawContent, isHtml: true }) ?? + addMessage(msg.content, senderRole, { + raw: rawContent, + isHtml: true, + senderType: msg.senderType ?? senderRole }); + + if (messageElement) { + messageCount += 1; } - } else if (msg.cmd) { + } else if (msg?.cmd) { if (msg.cmd === 'error') { - // TODO add error processing + console.error('[IntentionTest] Webview error message received:', msg); } else if (msg.cmd === 'clear') { - // TODO clear the chat and show no-message prompt again - const n = msg.toIndex ?? 0; - total_messages = n; - while (chatContainer.children.length > 2 * n) { + const targetCount = msg.toIndex ?? 0; + messageCount = targetCount; + while (chatContainer.children.length > 2 * targetCount) { chatContainer.removeChild(chatContainer.lastChild); } + if (chatContainer.children.length === 0) { + noMessagePrompt.style.display = 'block'; + } } } -}); \ No newline at end of file +}); From 57faa6e42cf946c0eb68748f0017276965b9a274 Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:00:42 +0800 Subject: [PATCH 03/13] feat(UI): match the vscode topic and add the jump and clear button --- src/sidebarView.ts | 2 + web/index.html | 13 ++++- web/index.js | 56 ++++++++++++++++----- web/styles.css | 120 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 173 insertions(+), 18 deletions(-) diff --git a/src/sidebarView.ts b/src/sidebarView.ts index e25920c..6b53b6a 100644 --- a/src/sidebarView.ts +++ b/src/sidebarView.ts @@ -34,6 +34,8 @@ export class TesterWebViewProvider implements vscode.WebviewViewProvider { if (msg.cmd === 'open-code' && msg.content && msg.lang) { const doc = await vscode.workspace.openTextDocument({ language: msg.lang, content: msg.content }); vscode.window.showTextDocument(doc); + } else if (msg.cmd === 'clear-chat') { + await this.showMessage({ cmd: 'clear' }); } }); diff --git a/web/index.html b/web/index.html index d013240..d9aa154 100644 --- a/web/index.html +++ b/web/index.html @@ -6,11 +6,20 @@ Chat Bot - - + +
+
+ Intention Test + LLM-based iterative test assistant +
+
+ + +
+

Intention Test 🧪

An LLM-based iterative test generator.
Waiting for request...
diff --git a/web/index.js b/web/index.js index f990c48..e0f31db 100644 --- a/web/index.js +++ b/web/index.js @@ -1,5 +1,6 @@ const noMessagePrompt = document.getElementById('no-message'); const chatContainer = document.getElementById('chat-container'); +const toolbar = document.getElementById('toolbar'); const body = document.body; const OPEN_CODE_ICON = ''; @@ -13,6 +14,19 @@ if (canConnectToVsCode) { window.vscode = acquireVsCodeApi(); } +const toolbarHandlers = { + 'clear-chat': requestClearConversation, + 'scroll-latest': scrollToLatest +}; + +toolbar?.querySelectorAll('[data-action]').forEach((button) => { + const action = button.dataset.action; + const handler = action ? toolbarHandlers[action] : undefined; + if (handler) { + button.addEventListener('click', handler); + } +}); + body.addEventListener('wheel', () => { lastUserScrollTime = Date.now(); }, { passive: true }); @@ -21,13 +35,38 @@ body.addEventListener('mousedown', () => { lastUserScrollTime = Date.now(); }); +function scrollToLatest() { + body.scrollTo({ + top: body.scrollHeight, + behavior: 'smooth' + }); +} + function maybeAutoScroll() { if (Date.now() - lastUserScrollTime > 3000) { - body.scrollTo({ - top: body.scrollHeight, - behavior: 'smooth' - }); + scrollToLatest(); + } +} + +function setConversationLength(targetCount = 0) { + const normalized = Math.max(0, targetCount); + messageCount = normalized; + const nodesToKeep = normalized * 2; + while (chatContainer.children.length > nodesToKeep) { + chatContainer.removeChild(chatContainer.lastChild); + } + noMessagePrompt.style.display = chatContainer.children.length === 0 ? 'block' : 'none'; +} + +function resetConversation() { + setConversationLength(0); +} + +function requestClearConversation() { + if (window.vscode) { + window.vscode.postMessage({ cmd: 'clear-chat' }); } + resetConversation(); } function createMessageContent(message, isHtml) { @@ -217,14 +256,7 @@ window.addEventListener('message', (event) => { if (msg.cmd === 'error') { console.error('[IntentionTest] Webview error message received:', msg); } else if (msg.cmd === 'clear') { - const targetCount = msg.toIndex ?? 0; - messageCount = targetCount; - while (chatContainer.children.length > 2 * targetCount) { - chatContainer.removeChild(chatContainer.lastChild); - } - if (chatContainer.children.length === 0) { - noMessagePrompt.style.display = 'block'; - } + setConversationLength(msg.toIndex ?? 0); } } }); diff --git a/web/styles.css b/web/styles.css index dc03486..156f817 100644 --- a/web/styles.css +++ b/web/styles.css @@ -5,6 +5,11 @@ --border-color: var(--vscode-editorWidget-border, rgba(0, 0, 0, 0.08)); --assistant-accent: var(--vscode-testing-iconPassed, #2ea043); --user-accent: var(--vscode-testing-iconQueued, #007acc); + --code-block-bg: var(--vscode-editor-background, #1e1e1e); + --code-block-foreground: var(--vscode-editor-foreground, #d4d4d4); + --code-block-border: var(--vscode-editor-lineHighlightBorder, rgba(255, 255, 255, 0.08)); + --link-color: var(--vscode-textLink-foreground, #3794ff); + --toolbar-bg: var(--vscode-sideBarSectionHeader-background, rgba(255, 255, 255, 0.2)); } html { @@ -13,7 +18,7 @@ html { body { margin: 0; - padding: 24px 16px 32px; + padding: 16px 16px 32px; display: flex; flex-direction: column; min-height: 100vh; @@ -25,6 +30,7 @@ body { box-sizing: border-box; } +#toolbar, #no-message, #chat-container { width: min(960px, 100%); @@ -148,16 +154,20 @@ body { .message pre { position: relative; - background-color: var(--vscode-editor-background, #1e1e1e); - color: var(--vscode-editor-foreground, #d4d4d4); + background-color: var(--code-block-bg); + color: var(--code-block-foreground); border-radius: 12px; padding: 16px 48px 16px 16px; margin: 0.6em 0; overflow-x: auto; - border: 1px solid var(--border-color); + border: 1px solid var(--code-block-border); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); } +.message a { + color: var(--link-color); +} + .message-header { font-size: 0.75rem; font-weight: 700; @@ -231,6 +241,54 @@ body { background-color: rgba(0, 0, 0, 0.08); } +.hljs { + background: transparent; + color: var(--code-block-foreground); +} + +.hljs-comment, +.hljs-quote { + color: var(--vscode-foreground, #6a9955); + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-literal { + color: var(--vscode-charts-blue, #569cd6); +} + +.hljs-string, +.hljs-title, +.hljs-name { + color: var(--vscode-charts-orange, #ce9178); +} + +.hljs-number, +.hljs-attr, +.hljs-symbol { + color: var(--vscode-charts-purple, #b267e6); +} + +.hljs-section, +.hljs-type, +.hljs-selector-id { + color: var(--vscode-charts-green, #4ec9b0); +} + +.hljs-attr, +.hljs-built_in { + color: var(--vscode-charts-blue, #569cd6); +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: 700; +} + @keyframes blink { 0%, @@ -247,3 +305,57 @@ body { #no-message { padding: 0 2em; } +#toolbar { + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--toolbar-bg); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 16px 20px; + margin-bottom: 12px; + gap: 12px; + backdrop-filter: blur(12px); +} + +#toolbar .title { + display: flex; + flex-direction: column; + gap: 4px; +} + +#toolbar .name { + font-weight: 700; + font-size: 1.1rem; +} + +#toolbar .tagline { + font-size: 0.85rem; + color: var(--vscode-descriptionForeground, rgba(0, 0, 0, 0.6)); +} + +.toolbar-actions { + display: flex; + gap: 8px; +} + +.toolbar-actions button { + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 8px 14px; + background-color: var(--surface-card); + color: inherit; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.toolbar-actions button:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08); +} + +.toolbar-actions button[data-action="clear-chat"] { + background-image: linear-gradient(135deg, rgba(255, 99, 72, 0.15), transparent); + border-color: rgba(255, 99, 72, 0.3); +} From fda5640ee63cf0408a69f00df2b67ffcb018ee84 Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:02:30 +0800 Subject: [PATCH 04/13] fix(UI): fix the code and message show container and the button logic --- src/client.ts | 47 +++++++++++++++++++--- src/extension.ts | 47 ++++++++++++++++++++-- src/sidebarView.ts | 10 +++-- web/index.html | 4 +- web/index.js | 99 ++++++++++++++++++++++++++++++++++++++++------ web/styles.css | 23 +++++++++++ 6 files changed, 203 insertions(+), 27 deletions(-) diff --git a/src/client.ts b/src/client.ts index c319a8c..ad8a014 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,11 +1,14 @@ // create a python subprocess and communicate with it through network -import { request, RequestOptions } from 'http'; +import { request, RequestOptions, ClientRequest } from 'http'; export class TesterSession { private updateMessageCallback?: (...args: any[]) => any; private errorCallbcak?: (...args: any[]) => any; private showNoRefMsg?: (...args: any[]) => any; private connectToPort: number; + private currentRequest?: ClientRequest; + private finishActiveRequest?: () => void; + private isCancelling = false; // setting connectToPort to 0 to start up an internal server constructor(updateMessageCallback?: (...args: any[]) => any, errorCallback?: (...args: any[]) => any, showNoRefMsg?: (...args: any[]) => any, connectToPort: number = 0) { @@ -66,7 +69,12 @@ export class TesterSession { }; let finish: (value?: any) => void; - const finishePromise = new Promise((res, rej) => { finish = res; }); + const finishePromise = new Promise((res) => { finish = res; }); + this.finishActiveRequest = () => { + finish(); + this.resetRequestState(); + }; + this.isCancelling = false; const req = request(options, (res) => { let status = 'before-start'; @@ -88,7 +96,7 @@ export class TesterSession { if (msg.type && msg.data) { if (msg.type === 'status' && msg.data.status === 'finish') { status = 'finished'; - finish(); + this.finishActiveRequest?.(); return; } else if (msg.type === 'msg' && msg.data.session_id && msg.data.messages) { if (this.updateMessageCallback) { @@ -109,26 +117,53 @@ export class TesterSession { } } catch (e) { - console.error(e); - cancelCb(e); + if (!this.isCancelling) { + console.error(e); + cancelCb(e); + } } }); res.on('end', () => { console.log('No more data in response.'); - // this.close(); + if (!this.isCancelling) { + this.resetRequestState(); + } }); res.on('error', (e) => { + if (this.isCancelling) { + return; + } console.error(e); }); }); + this.currentRequest = req; req.on('error', (e) => { + if (this.isCancelling) { + return; + } console.error(`Problem on request: ${e}`); }); req.write(requestData); req.end(); await finishePromise; + this.resetRequestState(); + } + + public cancelCurrentQuery(): void { + if (!this.currentRequest) { + return; + } + this.isCancelling = true; + this.currentRequest.destroy(); + this.finishActiveRequest?.(); + } + + private resetRequestState(): void { + this.currentRequest = undefined; + this.finishActiveRequest = undefined; + this.isCancelling = false; } } diff --git a/src/extension.ts b/src/extension.ts index 5c1a22d..6f0dcfd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,9 +8,12 @@ import { marked } from 'marked'; import { ExtensionMetadata } from './constants'; import { showANewEditorForInput } from './utils'; +let activeSession: TesterSession | undefined; + export function activate(context: vscode.ExtensionContext): void { const viewId = 'testView.sidebar'; const testerWebViewProvider = new TesterWebViewProvider(context); + testerWebViewProvider.setMessageHandler((msg) => handleWebviewCommand(msg, testerWebViewProvider)); context.subscriptions.push( vscode.window.registerWebviewViewProvider(viewId, testerWebViewProvider, { @@ -132,15 +135,24 @@ async function generateTest(focalMethod: string, focalFile: string, testDesc: st }, connectToPort ); + activeSession = session; + await sendSessionState(ui, 'running'); await ui.showMessage({ role: 'system-wait', content: 'Server is preparing...' }); // await session.connect(); - await session.startQuery(generateParams, (e: any) => { - vscode.window.showErrorMessage(`Query error when connecting to the server: ${e}`); - // ui.showMessage({ cmd: 'error', message: 'an error has occurred'}); - }); + try { + await session.startQuery(generateParams, (e: any) => { + vscode.window.showErrorMessage(`Query error when connecting to the server: ${e}`); + // ui.showMessage({ cmd: 'error', message: 'an error has occurred'}); + }); + } finally { + if (activeSession === session) { + activeSession = undefined; + } + await sendSessionState(ui, 'idle'); + } } // TODO add blocking to prevent 2 sessions at the same time, or allow parallel sessions in new tab @@ -198,4 +210,31 @@ async function showWait(msg: any, ui: TesterWebViewProvider): Promise { export function deactivate() { } +async function handleWebviewCommand(msg: any, ui: TesterWebViewProvider): Promise { + if (!(msg && msg.cmd)) { + return; + } + if (msg.cmd === 'stop-run') { + await stopActiveSession(ui); + } else if (msg.cmd === 'clear-chat') { + await ui.showMessage({ cmd: 'clear', toIndex: 0 }); + } +} + +async function stopActiveSession(ui: TesterWebViewProvider): Promise { + if (!activeSession) { + await sendSessionState(ui, 'idle'); + return; + } + activeSession.cancelCurrentQuery(); + activeSession = undefined; + await sendSessionState(ui, 'stopped', '生成已被手动停止。'); +} +async function sendSessionState( + ui: TesterWebViewProvider, + state: 'idle' | 'running' | 'stopped', + message?: string +): Promise { + await ui.showMessage({ cmd: 'session-state', state, message }); +} diff --git a/src/sidebarView.ts b/src/sidebarView.ts index 6b53b6a..a309587 100644 --- a/src/sidebarView.ts +++ b/src/sidebarView.ts @@ -1,7 +1,6 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; -import * as marked from 'marked'; import { detectCodeLang, extractGenTestCode, extractRefTestCode, isGenTestPrompt, langSuffix, shouldGenTestPrompt } from './textUtils'; import { CodeHistoryDiffPlayer } from './diffView'; @@ -15,6 +14,7 @@ export function setWebRoot(root: string) { export class TesterWebViewProvider implements vscode.WebviewViewProvider { private _context: vscode.ExtensionContext; private _view?: vscode.Webview; + private _messageHandler?: (msg: any) => Thenable | void; constructor(context: vscode.ExtensionContext) { this._context = context; @@ -34,13 +34,17 @@ export class TesterWebViewProvider implements vscode.WebviewViewProvider { if (msg.cmd === 'open-code' && msg.content && msg.lang) { const doc = await vscode.workspace.openTextDocument({ language: msg.lang, content: msg.content }); vscode.window.showTextDocument(doc); - } else if (msg.cmd === 'clear-chat') { - await this.showMessage({ cmd: 'clear' }); + } else if (this._messageHandler) { + await this._messageHandler(msg); } }); } + public setMessageHandler(handler: (msg: any) => Thenable | void): void { + this._messageHandler = handler; + } + private getHtmlContent(): string { const htmlPath = path.join(webRoot, 'index.html'); return fs.readFileSync(htmlPath, 'utf8'); diff --git a/web/index.html b/web/index.html index d9aa154..dfbe152 100644 --- a/web/index.html +++ b/web/index.html @@ -16,8 +16,8 @@ LLM-based iterative test assistant
- - + +
diff --git a/web/index.js b/web/index.js index e0f31db..611bb57 100644 --- a/web/index.js +++ b/web/index.js @@ -2,12 +2,24 @@ const noMessagePrompt = document.getElementById('no-message'); const chatContainer = document.getElementById('chat-container'); const toolbar = document.getElementById('toolbar'); const body = document.body; +const defaultNoMessageMarkup = noMessagePrompt.innerHTML; +const waitingNoMessageMarkup = ` +

Intention Test 🧪

+
正在等待新的响应...
+`.trim(); +const SessionState = { + IDLE: 'idle', + RUNNING: 'running', + STOPPING: 'stopping', + STOPPED: 'stopped' +}; const OPEN_CODE_ICON = ''; const RESTART_ICON = ''; let messageCount = 0; let lastUserScrollTime = Date.now(); +let sessionState = SessionState.IDLE; const canConnectToVsCode = typeof acquireVsCodeApi === 'function'; if (canConnectToVsCode) { @@ -16,7 +28,7 @@ if (canConnectToVsCode) { const toolbarHandlers = { 'clear-chat': requestClearConversation, - 'scroll-latest': scrollToLatest + 'stop-run': requestStopConversation }; toolbar?.querySelectorAll('[data-action]').forEach((button) => { @@ -26,6 +38,8 @@ toolbar?.querySelectorAll('[data-action]').forEach((button) => { button.addEventListener('click', handler); } }); +updateToolbarState(); +updatePlaceholderVisibility(); body.addEventListener('wheel', () => { lastUserScrollTime = Date.now(); @@ -48,25 +62,75 @@ function maybeAutoScroll() { } } -function setConversationLength(targetCount = 0) { - const normalized = Math.max(0, targetCount); +function trimConversationTo(targetCount = 0) { + const normalized = Math.max(0, Number.isFinite(targetCount) ? Math.trunc(targetCount) : 0); messageCount = normalized; const nodesToKeep = normalized * 2; while (chatContainer.children.length > nodesToKeep) { chatContainer.removeChild(chatContainer.lastChild); } - noMessagePrompt.style.display = chatContainer.children.length === 0 ? 'block' : 'none'; + if (normalized === 0) { + removeTypingAnimation(); + } + updatePlaceholderVisibility(); } -function resetConversation() { - setConversationLength(0); +function requestClearConversation() { + window.vscode?.postMessage({ cmd: 'clear-chat' }); + trimConversationTo(0); } -function requestClearConversation() { - if (window.vscode) { - window.vscode.postMessage({ cmd: 'clear-chat' }); +function requestStopConversation() { + if (sessionState !== SessionState.RUNNING) { + addSystemNotice('当前没有正在运行的请求。'); + return; + } + setSessionState(SessionState.STOPPING); + addSystemNotice('正在尝试停止当前生成...'); + window.vscode?.postMessage({ cmd: 'stop-run' }); +} + +function setSessionState(nextState) { + if (!nextState) { + return; + } + sessionState = nextState; + if (nextState !== SessionState.RUNNING) { + removeTypingAnimation(); + } + updateToolbarState(); + updatePlaceholderVisibility(); +} + +function updateToolbarState() { + if (!toolbar) { + return; + } + const stopButton = toolbar.querySelector('[data-action="stop-run"]'); + if (stopButton) { + const isStopping = sessionState === SessionState.STOPPING; + const canStop = sessionState === SessionState.RUNNING; + stopButton.disabled = !canStop; + stopButton.textContent = isStopping ? '停止中…' : '停止'; + } +} + +function updatePlaceholderVisibility() { + const hasMessages = chatContainer.children.length > 0; + if (hasMessages) { + noMessagePrompt.style.display = 'none'; + return; + } + if (sessionState === SessionState.RUNNING || sessionState === SessionState.STOPPING) { + noMessagePrompt.innerHTML = waitingNoMessageMarkup; + } else { + noMessagePrompt.innerHTML = defaultNoMessageMarkup; } - resetConversation(); + noMessagePrompt.style.display = 'block'; +} + +function addSystemNotice(message) { + addMessage(message, 'system', { senderType: 'system' }); } function createMessageContent(message, isHtml) { @@ -120,6 +184,7 @@ function addMessage(message, sender, options = {}) { } maybeAutoScroll(); + updatePlaceholderVisibility(); return messageElement; } @@ -212,6 +277,7 @@ function completeTypingAnimation(message, sender, options = {}) { typingElement.index = messageCount; maybeAutoScroll(); + updatePlaceholderVisibility(); return typingElement; } @@ -223,6 +289,7 @@ function removeTypingAnimation() { chatContainer.removeChild(header); } chatContainer.removeChild(typingElement); + updatePlaceholderVisibility(); } } @@ -253,10 +320,18 @@ window.addEventListener('message', (event) => { messageCount += 1; } } else if (msg?.cmd) { - if (msg.cmd === 'error') { + if (msg.cmd === 'session-state') { + const nextState = msg.state ?? SessionState.IDLE; + setSessionState(nextState); + if (typeof msg.message === 'string' && msg.message.trim().length > 0) { + addSystemNotice(msg.message); + } else if (nextState === SessionState.STOPPED) { + addSystemNotice('生成已停止,不再继续。'); + } + } else if (msg.cmd === 'error') { console.error('[IntentionTest] Webview error message received:', msg); } else if (msg.cmd === 'clear') { - setConversationLength(msg.toIndex ?? 0); + trimConversationTo(msg.toIndex ?? 0); } } }); diff --git a/web/styles.css b/web/styles.css index 156f817..5d5bb42 100644 --- a/web/styles.css +++ b/web/styles.css @@ -47,6 +47,7 @@ body { border-radius: 16px; border: 1px solid var(--border-color); box-shadow: 0 14px 30px rgba(0, 0, 0, 0.08); + box-sizing: border-box; } #no-message { @@ -73,11 +74,14 @@ body { border-radius: 12px; transition: all 0.25s ease-in-out; overflow: hidden; + width: 100%; max-width: 100%; background-color: var(--surface-card); border: 1px solid var(--border-color); padding: 12px 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); + box-sizing: border-box; + word-break: break-word; } .message-content { @@ -162,8 +166,15 @@ body { overflow-x: auto; border: 1px solid var(--code-block-border); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); + box-sizing: border-box; + max-width: 100%; } +.message pre code { + display: block; + width: 100%; + box-sizing: border-box; +} .message a { color: var(--link-color); } @@ -355,6 +366,18 @@ body { box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08); } +.toolbar-actions button:disabled { + opacity: 0.6; + cursor: default; + transform: none; + box-shadow: none; +} + +.toolbar-actions button[data-action="stop-run"] { + background-image: linear-gradient(135deg, rgba(255, 138, 0, 0.18), transparent); + border-color: rgba(255, 138, 0, 0.35); +} + .toolbar-actions button[data-action="clear-chat"] { background-image: linear-gradient(135deg, rgba(255, 99, 72, 0.15), transparent); border-color: rgba(255, 99, 72, 0.3); From 6d9a4e9557e54db707836403e73bd345db2d93e1 Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:06:26 +0800 Subject: [PATCH 05/13] fix(backend): modify to generate the tests successfully --- backend/agents.py | 25 +++++++++++++----- backend/configs.py | 5 ++-- backend/main.py | 63 ++++++++++++++++++++++++++++++++++++++-------- backend/server.py | 5 ++-- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/backend/agents.py b/backend/agents.py index d6b801f..b56c115 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -21,7 +21,13 @@ def __init__(self, llm_name: str): self.max_completion_tokens = 5120 def get_response(self, messages, n=1, skip_deepseek_think: bool=False): - if self.model_name == 'gpt-4o' or self.model_name == 'gpt-3.5-turbo': + if self.model_name in ( + 'gpt-4o', + 'gpt-3.5-turbo', + 'qwen-plus', + 'qwen-coder-plus', + 'qwen-long-latest', + ): if self.system_prompt: messages = [{'role': 'system', 'content': self.system_prompt}] + messages response = self._get_gpt_response(messages, n=n) @@ -57,14 +63,15 @@ def _get_gpt_response(self, messages, n=1): top_p=self.top_p, seed=self.seed, stream=False, - max_completion_tokens=self.max_completion_tokens, + max_tokens=self.max_completion_tokens, n=n, ) except Exception as e: print(f'\nError: {e}\n\n') n_tries += 1 if n_tries > max_tries: - each_response = '```\n\n[ERROR] Failed to generate\n\n```' + fallback = '```\n[ERROR] Failed to generate due to API error or quota.\n```' + response.append(fallback) break continue @@ -91,7 +98,7 @@ def _get_gpt_o1_mini_response(self, messages, n=1): temperature=self.temp, seed=self.seed, stream=False, - max_completion_tokens=self.max_completion_tokens, + max_tokens=self.max_completion_tokens, n=n, ) except Exception as e: @@ -124,7 +131,8 @@ def _get_gpt_o1_mini_response(self, messages, n=1): n_tries += 1 if n_tries > max_tries: - each_response = '```\n\n[ERROR] Failed to generate\n\n```' + fallback = '```\n[ERROR] Failed to generate due to API error or quota.\n```' + response.append(fallback) break continue @@ -154,7 +162,7 @@ def _get_deepseek_qwen_response(self, messages, n=1, skip_deepseek_think: bool=F temperature=0.6, seed=self.seed, stream=False, - max_completion_tokens=self.max_completion_tokens, + max_tokens=self.max_completion_tokens, n=1 ) except Exception as e: @@ -173,6 +181,11 @@ def _get_deepseek_qwen_response(self, messages, n=1, skip_deepseek_think: bool=F messages[0]['content'] = messages[0]['content'].replace(context_part, reduced_context_part) continue + # when API/quota errors persist, append fallback to avoid empty response + n_tries += 1 + if n_tries >= max_tries: + response.append('```\n[ERROR] Failed to generate due to API error or quota.\n```') + break print(f'Time consuming for one generation: {time.time()-s_time:.2f} seconds\n\n') print(f'[INFO] Response:\n{each_response_raw.choices[0].message.content}\n\n\n') diff --git a/backend/configs.py b/backend/configs.py index a433a58..209d1ef 100644 --- a/backend/configs.py +++ b/backend/configs.py @@ -10,7 +10,7 @@ def __init__(self, project_name, tester_path = '') -> None: os.environ['OPENAI_BASE_URL'] = self.openai_url self.project_name = project_name - self.llm_name = 'gpt-4o' + self.llm_name = global_config['openai'].get('model', 'gpt-4o') self.max_context_len = 1024 self.max_input_len = 4096 @@ -22,7 +22,8 @@ def __init__(self, project_name, tester_path = '') -> None: else: self.workspace = f'{self.root_dir}/intention_test_extension' - self.corpus_path = f'{self.workspace}/data/collected_coverages/{project_name}.json' + # Align with dump_collect_pairs: it writes to backend/data/{project}.json + self.corpus_path = f'{self.root_dir}/data/{project_name}.json' self.project_without_test_file_path = f'{self.workspace}/data/repos_removing_test/{project_name}' self.project_with_test_file_path = f'{self.workspace}/data/repos_with_test/{project_name}' diff --git a/backend/main.py b/backend/main.py index 4523c31..0df7e37 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,6 +6,7 @@ from dataset import Dataset from configs import Configs from server import ModelQuerySession +from typing import Optional import pathlib from extension_api.collect_pairs.main import dump_collect_pairs @@ -36,22 +37,47 @@ def load_corpus(self): corpus_fm, corpus_fm_name, corpus_context, corpus_tc_name, corpus_test_case_path = [], [], [], [], [] for each_data in all_data: - corpus_fm.append(''.join(each_data['target_coverage']).replace('', '')) - corpus_fm_name.append(each_data['focal_method_name']) - corpus_context.append(each_data['target_context']) - corpus_tc_name.append(each_data['target_test_case_name'].split('::::')[-1].split('(')[0]) - corpus_test_case_path.append(each_data['focal_file_path'].replace('src/main/java', 'src/test/java').replace('.java', 'Test.java')) + if 'target_coverage' in each_data: + # original expected format + corpus_fm.append(''.join(each_data['target_coverage']).replace('', '')) + corpus_fm_name.append(each_data.get('focal_method_name', '')) + corpus_context.append(each_data.get('target_context', '')) + tc_name = each_data.get('target_test_case_name', '') + corpus_tc_name.append(tc_name.split('::::')[-1].split('(')[0] if tc_name else '') + focal_file_path = each_data.get('focal_file_path', '') + if focal_file_path: + corpus_test_case_path.append(focal_file_path.replace('src/main/java', 'src/test/java').replace('.java', 'Test.java')) + else: + corpus_test_case_path.append('') + else: + # fallback to collect_pairs schema + # focal method text + fm = each_data.get('focal_method', []) + corpus_fm.append(''.join(fm) if isinstance(fm, list) else str(fm)) + # focal method name + corpus_fm_name.append(each_data.get('focal_method_name', '')) + # use focal method content as context (best available without re-reading files) + corpus_context.append(''.join(fm) if isinstance(fm, list) else str(fm)) + # derive test case simple name from test_name or test_path + test_name = each_data.get('test_name', '') + if test_name: + corpus_tc_name.append(test_name.split('(')[0].split('::::')[-1]) + else: + test_path = each_data.get('test_path', '') + corpus_tc_name.append(os.path.splitext(os.path.basename(test_path))[0] if test_path else '') + # test case path provided directly + corpus_test_case_path.append(each_data.get('test_path', '')) self.corpus = { - 'corpus_fm': corpus_fm, - 'corpus_fm_name': corpus_fm_name, + 'corpus_fm': corpus_fm, + 'corpus_fm_name': corpus_fm_name, 'corpus_context': corpus_context, - 'corpus_tc_name': corpus_tc_name, + 'corpus_tc_name': corpus_tc_name, 'corpus_test_case_path': corpus_test_case_path - } + } -def main(target_focal_method, target_focal_file, test_desc, project_path, focal_file_path, query_session: ModelQuerySession | None = None): +def main(target_focal_method, target_focal_file, test_desc, project_path, focal_file_path, query_session: Optional[ModelQuerySession] = None): # project_name = project_path.split('/')[-1] not compatible with Windows path project_name = pathlib.Path(project_path).stem # replace the disk letter to upper case to match CodeQL path @@ -98,7 +124,22 @@ def main(target_focal_method, target_focal_file, test_desc, project_path, focal_ target_test_case_desc = test_desc_data['test_desc']['under_setting'] # TODO LSP now cannot run in Windows - offline_fact_ref_data = dataset.load_offline_fact_ref_data() + try: + offline_fact_ref_data = dataset.load_offline_fact_ref_data() + except FileNotFoundError: + # Fallback: construct empty facts/references with proper length + corpus_len = len(intention_test.corpus['corpus_fm_name']) if intention_test.corpus else 0 + offline_fact_ref_data = [ + { + 'target_coverage_idx': i, + 'rag_references': [], + 'disc_facts': [], + 'disc_facts_sim': [], + 'top_usages': [], + 'top_usages_sim': [] + } + for i in range(corpus_len) + ] # prepare test generator dtester = IntentionTester(configs) diff --git a/backend/server.py b/backend/server.py index e7f7774..d7bab88 100644 --- a/backend/server.py +++ b/backend/server.py @@ -12,6 +12,7 @@ import argparse import main import hashlib +from typing import Optional, Union port = 8080 @@ -23,7 +24,7 @@ global_junit_version = 4 class StatusMessage: - def __init__(self, status: str, message: str | dict = ''): + def __init__(self, status: str, message: Union[str, dict] = ''): self.status = status self.message = message @@ -191,7 +192,7 @@ def get_hash(s: str): sessions: dict[str, ModelQuerySession] = {} -def assign_to_session(query_text: str, query_handler: QueryHandler) -> ModelQuerySession | None: +def assign_to_session(query_text: str, query_handler: QueryHandler) -> Optional[ModelQuerySession]: # do with sessions query_data = json.loads(query_text) if query_data['type'] != 'query': From 0c446edc570a8e364b56e2664ea41c4609b51f9e Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:07:30 +0800 Subject: [PATCH 06/13] feat(backend): complete the stop logic --- backend/exceptions.py | 4 ++ backend/generator.py | 15 ++++++- backend/server.py | 91 +++++++++++++++++++++++++++++++++++-------- src/client.ts | 39 +++++++++++++++++++ src/extension.ts | 7 +++- 5 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 backend/exceptions.py diff --git a/backend/exceptions.py b/backend/exceptions.py new file mode 100644 index 0000000..2cd57f7 --- /dev/null +++ b/backend/exceptions.py @@ -0,0 +1,4 @@ +class GenerationCancelled(Exception): + """Raised when a tester session is cancelled by the user.""" + pass + diff --git a/backend/generator.py b/backend/generator.py index 71f0a9c..a99133b 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -8,6 +8,7 @@ from configs import Configs from agents import TestGenAgent, TestRefineAgent from test_case_runner import TestCaseRunner +from exceptions import GenerationCancelled class IntentionTester: @@ -29,16 +30,22 @@ def update_messages_to_remote(self, messages): if self.query_session: self.query_session.update_messages(messages) + def _ensure_not_cancelled(self): + if self.query_session and self.query_session.should_stop(): + raise GenerationCancelled() + def generate_test_case_with_refine(self, target_focal_method, target_context, target_test_case_desc, target_test_case_path, referable_test_case, facts, junit_version, prohibit_fact: bool = False, query_session: ModelQuerySession | None = None): self.generation_with_refine_log = [] self.query_session = query_session + self._ensure_not_cancelled() target_test_class_name = target_test_case_path.split('/')[-1].replace('.java', '') gen_test_case, prompt, messages = self.generate_test_case(target_focal_method, target_context, target_test_class_name, target_test_case_desc, referable_test_case, facts, junit_version, prohibit_fact) self.update_messages_to_remote(messages) + self._ensure_not_cancelled() error_msg, test_status = self.run_test_case(gen_test_case, target_test_case_path) self.generation_with_refine_log.append((test_status, prompt, gen_test_case)) @@ -47,9 +54,11 @@ def generate_test_case_with_refine(self, return gen_test_case, test_status, messages for round in range(self.max_round): + self._ensure_not_cancelled() gen_test_case, prompt, refine_messages = self.refine(gen_test_case, error_msg, target_focal_method, target_context, target_test_case_desc, target_test_case_path, facts, prohibit_fact) messages += refine_messages self.update_messages_to_remote(messages) + self._ensure_not_cancelled() error_msg, test_status = self.run_test_case(gen_test_case, target_test_case_path) self.generation_with_refine_log.append((test_status, prompt, gen_test_case)) @@ -61,14 +70,17 @@ def generate_test_case_with_refine(self, return gen_test_case, test_status, messages def finish_generate(self): + self._ensure_not_cancelled() messages = self.test_gen_agent.generate_finish() return messages def generate_test_case(self, target_focal_method, target_context, target_test_class_name, target_test_case_desc, referable_test_case, facts, junit_version, prohibit_fact): + self._ensure_not_cancelled() gen_test_case, prompt, messages = self.test_gen_agent.generate_test_case(target_focal_method, target_context, target_test_class_name, target_test_case_desc, referable_test_case, facts, junit_version, prohibit_fact) return gen_test_case, prompt, messages def refine(self, gen_test_case, error_msg, target_focal_method, target_context, target_test_case_desc, target_test_case_path, facts: list, prohibit_fact): + self._ensure_not_cancelled() error_msg_lines = error_msg.split('\n') error_msg_cut = '\n'.join(error_msg_lines[:self.max_line_error_msg]) @@ -76,6 +88,7 @@ def refine(self, gen_test_case, error_msg, target_focal_method, target_context, return refined_tc, prompt, messages def run_test_case(self, test_case, test_case_path): + self._ensure_not_cancelled() def _extract_error_msg(log): error_msg = [] stop_flag = False @@ -130,4 +143,4 @@ def _extract_error_msg(log): error_msg = "" test_status = 'success' - return error_msg, test_status \ No newline at end of file + return error_msg, test_status diff --git a/backend/server.py b/backend/server.py index d7bab88..81a95d6 100644 --- a/backend/server.py +++ b/backend/server.py @@ -12,8 +12,11 @@ import argparse import main import hashlib +import uuid from typing import Optional, Union +from exceptions import GenerationCancelled + port = 8080 # a standard python logger @@ -77,14 +80,14 @@ def do_POST(self): return if query_session: - self.send_keep_alive_header() - # self.write_single_line(StatusMessage('start').response()) - query_session.write_start_message() - query_session.start_query() - # self.write_single_line(StatusMessage('finish').response()) - query_session.write_finish_message() - # no need to flush because the handle_one_request will do that - self.end_session() + try: + self.send_keep_alive_header() + query_session.write_start_message() + query_session.start_query() + query_session.write_finish_message() + self.end_session() + finally: + remove_session(query_session.session_id) else: raise ValueError("No query session can be constructed or retrieved from request") @@ -103,6 +106,30 @@ def do_POST(self): logger.error(f"Error handling request. Message: {e}. Request:\n{self.request}\n{traceback.format_exc()}") self.end_with_internal_error(str(e)) + elif self.path == '/session/stop': + try: + payload_length = int(self.headers.get('Content-Length', 0)) + request_data = self.rfile.read(payload_length).decode('utf-8') + payload = json.loads(request_data) + session_id = payload.get('session_id') + if not session_id: + self.end_with_request_error('Missing session_id') + return + session = get_session(session_id) + if not session: + self.send_response(404, 'Session Not Found') + self.end_headers() + return + session.request_stop() + self.send_response(200, 'Stopping') + self.end_headers() + except json.JSONDecodeError as e: + logger.error(f'Error decoding stop payload: {e}') + self.end_with_request_error(str(e)) + except Exception as e: + logger.error(f'Failed to stop session: {e}\n{traceback.format_exc()}') + self.end_with_internal_error(str(e)) + else: self.send_response(404) self.end_headers() @@ -146,6 +173,7 @@ def __init__(self, session_id: str, raw_data: dict, handler: QueryHandler): self.query_data = self.prepare_query_arguments() self.session_running = False + self._cancel_event = threading.Event() def prepare_query_arguments(self): # do with session_meta_data @@ -155,8 +183,12 @@ def start_query(self): if not self.session_running: self.session_running = True logger.info(f'Starting query session {self.session_id}') - main.main(**self.query_data, query_session = self) - self.session_running = False + try: + main.main(**self.query_data, query_session = self) + except GenerationCancelled: + logger.info(f'Query session {self.session_id} cancelled by user') + finally: + self.session_running = False def update_messages(self, messages): self.messages = messages @@ -164,26 +196,39 @@ def update_messages(self, messages): 'session_id': self.session_id, 'messages': messages } - self.handler.write_single_line(ModelMessage(data_to_send).response()) + self._safe_write(ModelMessage(data_to_send).response()) def write_start_message(self): data = { 'session_id': self.session_id } - self.handler.write_single_line(StatusMessage('start', data).response()) + self._safe_write(StatusMessage('start', data).response()) def write_noref_message(self): data = { 'session_id': self.session_id, 'junit_version': self.junit_version } - self.handler.write_single_line(NoRefMessage(data).response()) + self._safe_write(NoRefMessage(data).response()) def write_finish_message(self): data = { 'session_id': self.session_id } - self.handler.write_single_line(StatusMessage('finish', data).response()) + self._safe_write(StatusMessage('finish', data).response()) + + def request_stop(self): + self._cancel_event.set() + + def should_stop(self): + return self._cancel_event.is_set() + + def _safe_write(self, payload: bytes): + try: + self.handler.write_single_line(payload) + except BrokenPipeError: + logger.warning(f'Connection closed for session {self.session_id}') + self.request_stop() # Not used now, we still send raw time def get_hash(s: str): @@ -191,6 +236,19 @@ def get_hash(s: str): return h.hexdigest() sessions: dict[str, ModelQuerySession] = {} +sessions_lock = threading.Lock() + +def register_session(session: ModelQuerySession) -> None: + with sessions_lock: + sessions[session.session_id] = session + +def remove_session(session_id: str) -> None: + with sessions_lock: + sessions.pop(session_id, None) + +def get_session(session_id: str) -> Optional[ModelQuerySession]: + with sessions_lock: + return sessions.get(session_id) def assign_to_session(query_text: str, query_handler: QueryHandler) -> Optional[ModelQuerySession]: # do with sessions @@ -198,8 +256,9 @@ def assign_to_session(query_text: str, query_handler: QueryHandler) -> Optional[ if query_data['type'] != 'query': raise NotImplementedError('None query is not supported yet') - time_str = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S %Z') - new_session = ModelQuerySession(time_str, query_data['data'], query_handler) + session_id = uuid.uuid4().hex + new_session = ModelQuerySession(session_id, query_data['data'], query_handler) + register_session(new_session) return new_session # TODO sometimes session should be retrived, return None if not found diff --git a/src/client.ts b/src/client.ts index ad8a014..1e091fa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,6 +9,7 @@ export class TesterSession { private currentRequest?: ClientRequest; private finishActiveRequest?: () => void; private isCancelling = false; + private activeSessionId?: string; // setting connectToPort to 0 to start up an internal server constructor(updateMessageCallback?: (...args: any[]) => any, errorCallback?: (...args: any[]) => any, showNoRefMsg?: (...args: any[]) => any, connectToPort: number = 0) { @@ -56,6 +57,7 @@ export class TesterSession { async startQuery(args: any, cancelCb: (e: any) => any) { const requestData = new TextEncoder().encode(JSON.stringify({ type: 'query', data: args }) + '\n'); + this.activeSessionId = undefined; const options: RequestOptions = { hostname: 'localhost', @@ -90,12 +92,14 @@ export class TesterSession { if (!(msg.type && msg.data && msg.type === 'status' && msg.data.status === 'start')) { throw TypeError('Failed to receive start message'); } + this.activeSessionId = msg.data.session_id; status = 'started'; } else if (status !== 'finished') { // receive messages if (msg.type && msg.data) { if (msg.type === 'status' && msg.data.status === 'finish') { status = 'finished'; + this.activeSessionId = undefined; this.finishActiveRequest?.(); return; } else if (msg.type === 'msg' && msg.data.session_id && msg.data.messages) { @@ -160,10 +164,45 @@ export class TesterSession { this.currentRequest.destroy(); this.finishActiveRequest?.(); } + + public async stopActiveSession(): Promise { + this.cancelCurrentQuery(); + await this.sendStopSignal(); + } private resetRequestState(): void { this.currentRequest = undefined; this.finishActiveRequest = undefined; this.isCancelling = false; } + + private async sendStopSignal(): Promise { + if (!this.activeSessionId) { + return; + } + const payload = Buffer.from(JSON.stringify({ session_id: this.activeSessionId }), 'utf-8'); + const options: RequestOptions = { + hostname: 'localhost', + port: this.connectToPort, + path: '/session/stop', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': payload.length.toString() + } + }; + + await new Promise((resolve) => { + const req = request(options, () => { + resolve(); + }); + req.on('error', (err) => { + console.error(`Failed to stop backend session: ${err}`); + resolve(); + }); + req.write(payload); + req.end(); + }); + this.activeSessionId = undefined; + } } diff --git a/src/extension.ts b/src/extension.ts index 6f0dcfd..035a046 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -226,8 +226,11 @@ async function stopActiveSession(ui: TesterWebViewProvider): Promise { await sendSessionState(ui, 'idle'); return; } - activeSession.cancelCurrentQuery(); - activeSession = undefined; + try { + await activeSession.stopActiveSession(); + } finally { + activeSession = undefined; + } await sendSessionState(ui, 'stopped', '生成已被手动停止。'); } From 79e3103a1b60d1ad261be76ebecb6b0e082152aa Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:09:25 +0800 Subject: [PATCH 07/13] feat(backend): optimize the http request, ensure it is safe and completed --- backend/server.py | 49 ++++++++++++++++++------------------- backend/session_registry.py | 31 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 backend/session_registry.py diff --git a/backend/server.py b/backend/server.py index 81a95d6..001e253 100644 --- a/backend/server.py +++ b/backend/server.py @@ -16,6 +16,7 @@ from typing import Optional, Union from exceptions import GenerationCancelled +from session_registry import SessionRegistry port = 8080 @@ -25,6 +26,7 @@ logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(asctime)s] [%(levelname)s] %(message)s') global_junit_version = 4 +session_registry = SessionRegistry() class StatusMessage: def __init__(self, status: str, message: Union[str, dict] = ''): @@ -87,7 +89,7 @@ def do_POST(self): query_session.write_finish_message() self.end_session() finally: - remove_session(query_session.session_id) + session_registry.remove(query_session.session_id) else: raise ValueError("No query session can be constructed or retrieved from request") @@ -115,7 +117,7 @@ def do_POST(self): if not session_id: self.end_with_request_error('Missing session_id') return - session = get_session(session_id) + session = session_registry.get(session_id) if not session: self.send_response(404, 'Session Not Found') self.end_headers() @@ -235,42 +237,39 @@ def get_hash(s: str): h = hashlib.sha256(s.encode('utf-8')) return h.hexdigest() -sessions: dict[str, ModelQuerySession] = {} -sessions_lock = threading.Lock() - -def register_session(session: ModelQuerySession) -> None: - with sessions_lock: - sessions[session.session_id] = session - -def remove_session(session_id: str) -> None: - with sessions_lock: - sessions.pop(session_id, None) - -def get_session(session_id: str) -> Optional[ModelQuerySession]: - with sessions_lock: - return sessions.get(session_id) - def assign_to_session(query_text: str, query_handler: QueryHandler) -> Optional[ModelQuerySession]: - # do with sessions query_data = json.loads(query_text) - if query_data['type'] != 'query': - raise NotImplementedError('None query is not supported yet') - + request_payload = validate_query_payload(query_data) + session_id = uuid.uuid4().hex - new_session = ModelQuerySession(session_id, query_data['data'], query_handler) - register_session(new_session) + new_session = ModelQuerySession(session_id, request_payload, query_handler) + session_registry.register(new_session) return new_session - # TODO sometimes session should be retrived, return None if not found + +def validate_query_payload(payload: dict) -> dict: + if payload.get('type') != 'query': + raise ValueError('Unsupported request type') + data = payload.get('data') + if not isinstance(data, dict): + raise ValueError('Query data must be a JSON object') + missing = [field for field in ModelQuerySession.required_fields if field not in data] + if missing: + raise ValueError(f"Missing required fields: {', '.join(missing)}") + return data # def find_open_port(): # with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: # s.bind(('', 0)) # Bind to any available port # return s.getsockname()[1] # Return the port number +class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + daemon_threads = True + allow_reuse_address = True + def start_http_server(port: int): logger.info(f'Starting HTTP server on port {port}') - httpd = socketserver.TCPServer(("", port), QueryHandler) + httpd = ThreadedTCPServer(("", port), QueryHandler) port = httpd.server_address[1] logger.info(f'HTTP server is started and listening on {port}') diff --git a/backend/session_registry.py b/backend/session_registry.py new file mode 100644 index 0000000..4938fed --- /dev/null +++ b/backend/session_registry.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import threading +from typing import Dict, Optional, TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + from server import ModelQuerySession + + +class SessionRegistry: + """线程安全的会话注册表,用于追踪活跃的生成会话。""" + + def __init__(self) -> None: + self._sessions: Dict[str, "ModelQuerySession"] = {} + self._lock = threading.Lock() + + def register(self, session: "ModelQuerySession") -> None: + with self._lock: + self._sessions[session.session_id] = session + + def remove(self, session_id: str) -> None: + with self._lock: + self._sessions.pop(session_id, None) + + def get(self, session_id: str) -> Optional["ModelQuerySession"]: + with self._lock: + return self._sessions.get(session_id) + + def list_active_ids(self) -> Iterable[str]: + with self._lock: + return tuple(self._sessions.keys()) From 48141aee1cf06a14cfbcafb88740633f93360e4f Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:10:50 +0800 Subject: [PATCH 08/13] fix(UI and Request): fix the display and stop logic --- backend/agents.py | 37 +++++++++++++++++++++++++++++-------- backend/generator.py | 17 +++++++++++++++-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/backend/agents.py b/backend/agents.py index b56c115..2ff0a24 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -2,9 +2,12 @@ import re import time +from typing import Callable from openai import OpenAI +from exceptions import GenerationCancelled + class Agent: def __init__(self, llm_name: str): @@ -19,15 +22,11 @@ def __init__(self, llm_name: str): self.top_p = 0.1 self.seed = 1203 self.max_completion_tokens = 5120 + self.cancel_check: Callable[[], bool] = lambda: False def get_response(self, messages, n=1, skip_deepseek_think: bool=False): - if self.model_name in ( - 'gpt-4o', - 'gpt-3.5-turbo', - 'qwen-plus', - 'qwen-coder-plus', - 'qwen-long-latest', - ): + self._check_cancel() + if self.model_name == 'gpt-4o' or self.model_name == 'gpt-3.5-turbo': if self.system_prompt: messages = [{'role': 'system', 'content': self.system_prompt}] + messages response = self._get_gpt_response(messages, n=n) @@ -47,12 +46,23 @@ def get_response(self, messages, n=1, skip_deepseek_think: bool=False): else: raise ValueError(f"Unknown LLM name: {self.model_name}") return response + + def set_cancel_check(self, checker: Callable[[], bool] | None) -> None: + if checker: + self.cancel_check = checker + else: + self.cancel_check = lambda: False + + def _check_cancel(self) -> None: + if self.cancel_check and self.cancel_check(): + raise GenerationCancelled() def _get_gpt_response(self, messages, n=1): response = [] max_tries = n + 2 n_tries = 0 while len(response) < n: + self._check_cancel() s_time = time.time() try: print(f'\n\n{messages}\n\n') @@ -67,6 +77,7 @@ def _get_gpt_response(self, messages, n=1): n=n, ) except Exception as e: + self._check_cancel() print(f'\nError: {e}\n\n') n_tries += 1 if n_tries > max_tries: @@ -78,6 +89,7 @@ def _get_gpt_response(self, messages, n=1): print(f'\nTime consuming for one generation: {time.time()-s_time:.2f} seconds\n\n\n') response.append(each_response.choices[0].message.content) + self._check_cancel() if n == 1: response = response[0] @@ -89,6 +101,7 @@ def _get_gpt_o1_mini_response(self, messages, n=1): max_tries = n + 2 n_tries = 0 while len(response) < n: + self._check_cancel() s_time = time.time() try: print(f'\n\n{messages}\n\n') @@ -102,9 +115,11 @@ def _get_gpt_o1_mini_response(self, messages, n=1): n=n, ) except Exception as e: + self._check_cancel() print(f'\nError: {e}\n\n') if "无可用渠道" in str(e): time.sleep(2) + self._check_cancel() continue if "potentially violating our usage policy" in str(e) or 'bad response status' in str(e): # triggered by o1-mini @@ -123,10 +138,12 @@ def _get_gpt_o1_mini_response(self, messages, n=1): part_2_1 = '\n'.join(part_2_1_lines) messages[1]['content'] = part_1 + '(with some details omitted):\n```\n' + part_2_1 + '\n```' + part_2_2 + self._check_cancel() continue if "quota is not enough" in str(e): time.sleep(10) + self._check_cancel() continue n_tries += 1 @@ -139,6 +156,7 @@ def _get_gpt_o1_mini_response(self, messages, n=1): print(f'\nTime consuming for one generation: {time.time()-s_time:.2f} seconds\n\n\n') response.append(each_response.choices[0].message.content) + self._check_cancel() if n == 1: response = response[0] @@ -154,6 +172,7 @@ def _get_deepseek_qwen_response(self, messages, n=1, skip_deepseek_think: bool=F messages[0]['content'] += '\n\n\nSkip Thinking\n\n\n' while len(response) < n: + self._check_cancel() s_time = time.time() try: each_response_raw = self.client.chat.completions.create( @@ -166,6 +185,7 @@ def _get_deepseek_qwen_response(self, messages, n=1, skip_deepseek_think: bool=F n=1 ) except Exception as e: + self._check_cancel() # the input is too long if 'Please reduce the length' in str(e): context_part = messages[0]['content'].split('(with some details omitted):')[1] @@ -204,6 +224,7 @@ def _get_deepseek_qwen_response(self, messages, n=1, skip_deepseek_think: bool=F each_response = '```\nFailed to generate\n```' response.append(each_response) + self._check_cancel() if n == 1: response = response[0] @@ -408,4 +429,4 @@ def construct_prompt(self, gen_test_case, error_msg, target_focal_method, target instruction += f"""# Output Requirements\nYour final output must strictly adhere to the following format:\n1: Begin with the exact prefix: "{self.gen_prefix}".\n2: End with the exact suffix: "{self.gen_suffix}".\nEnsure that no additional text appears before the prefix or after the suffix.""" - return instruction \ No newline at end of file + return instruction diff --git a/backend/generator.py b/backend/generator.py index a99133b..7859f9b 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -4,11 +4,11 @@ from pyexpat.errors import messages -from server import ModelQuerySession +from session_registry import ModelQuerySession +from exceptions import GenerationCancelled from configs import Configs from agents import TestGenAgent, TestRefineAgent from test_case_runner import TestCaseRunner -from exceptions import GenerationCancelled class IntentionTester: @@ -21,9 +21,13 @@ def __init__(self, configs: Configs, max_round=3, skip_deepseek_think: bool = Fa self.test_refine_agent = TestRefineAgent(configs.llm_name, configs.project_name, configs.project_url, n_responses=1, skip_deepseek_think=skip_deepseek_think) self.test_runner = TestCaseRunner(configs, configs.test_case_run_log_dir) self.generation_with_refine_log = [] # [(test_status, prompt, test_case)] + self.query_session: ModelQuerySession | None = None + self._cancel_check = lambda: False + self._apply_cancel_hook() def connect_to_request_session(self, query_session: ModelQuerySession): self.query_session = query_session + self._apply_cancel_hook() def update_messages_to_remote(self, messages): # TODO notify front-end for messages, maybe trasmit full (instead of transmit update only)? @@ -40,6 +44,7 @@ def generate_test_case_with_refine(self, prohibit_fact: bool = False, query_session: ModelQuerySession | None = None): self.generation_with_refine_log = [] self.query_session = query_session + self._apply_cancel_hook() self._ensure_not_cancelled() target_test_class_name = target_test_case_path.split('/')[-1].replace('.java', '') @@ -144,3 +149,11 @@ def _extract_error_msg(log): test_status = 'success' return error_msg, test_status + + def _apply_cancel_hook(self): + def cancel_check() -> bool: + return bool(self.query_session and self.query_session.should_stop()) + + self._cancel_check = cancel_check + self.test_gen_agent.set_cancel_check(cancel_check) + self.test_refine_agent.set_cancel_check(cancel_check) From 1c1fc8a2145acc75dafd8bb4b92825a80044782c Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:30:18 +0800 Subject: [PATCH 09/13] feat(backend): some modular process --- backend/agents.py | 10 +- backend/configs.py | 1 + backend/exceptions.py | 4 - backend/generator.py | 4 +- backend/modules/exceptions.py | 5 + backend/modules/messages.py | 42 ++ .../registry.py} | 13 +- backend/modules/session.py | 85 ++++ backend/server.py | 449 +++++++----------- 9 files changed, 321 insertions(+), 292 deletions(-) delete mode 100644 backend/exceptions.py create mode 100644 backend/modules/exceptions.py create mode 100644 backend/modules/messages.py rename backend/{session_registry.py => modules/registry.py} (58%) create mode 100644 backend/modules/session.py diff --git a/backend/agents.py b/backend/agents.py index 2ff0a24..6591427 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -6,7 +6,7 @@ from openai import OpenAI -from exceptions import GenerationCancelled +from modules.exceptions import GenerationCancelled class Agent: @@ -26,7 +26,13 @@ def __init__(self, llm_name: str): def get_response(self, messages, n=1, skip_deepseek_think: bool=False): self._check_cancel() - if self.model_name == 'gpt-4o' or self.model_name == 'gpt-3.5-turbo': + if self.model_name in ( + 'gpt-4o', + 'gpt-3.5-turbo', + 'qwen-plus', + 'qwen-coder-plus', + 'qwen-long-latest', + ): if self.system_prompt: messages = [{'role': 'system', 'content': self.system_prompt}] + messages response = self._get_gpt_response(messages, n=n) diff --git a/backend/configs.py b/backend/configs.py index 209d1ef..65ff9c4 100644 --- a/backend/configs.py +++ b/backend/configs.py @@ -10,6 +10,7 @@ def __init__(self, project_name, tester_path = '') -> None: os.environ['OPENAI_BASE_URL'] = self.openai_url self.project_name = project_name + # allow overriding model via config.ini -> [openai] model = ... self.llm_name = global_config['openai'].get('model', 'gpt-4o') self.max_context_len = 1024 diff --git a/backend/exceptions.py b/backend/exceptions.py deleted file mode 100644 index 2cd57f7..0000000 --- a/backend/exceptions.py +++ /dev/null @@ -1,4 +0,0 @@ -class GenerationCancelled(Exception): - """Raised when a tester session is cancelled by the user.""" - pass - diff --git a/backend/generator.py b/backend/generator.py index 7859f9b..bbae9fc 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -4,8 +4,8 @@ from pyexpat.errors import messages -from session_registry import ModelQuerySession -from exceptions import GenerationCancelled +from modules.session import ModelQuerySession +from modules.exceptions import GenerationCancelled from configs import Configs from agents import TestGenAgent, TestRefineAgent from test_case_runner import TestCaseRunner diff --git a/backend/modules/exceptions.py b/backend/modules/exceptions.py new file mode 100644 index 0000000..06b6287 --- /dev/null +++ b/backend/modules/exceptions.py @@ -0,0 +1,5 @@ +class GenerationCancelled(Exception): + """Raised when a generation session is cancelled by the user.""" + + def __init__(self, message: str = "Generation cancelled by user") -> None: + super().__init__(message) diff --git a/backend/modules/messages.py b/backend/modules/messages.py new file mode 100644 index 0000000..195c262 --- /dev/null +++ b/backend/modules/messages.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict, Union + + +def _to_bytes(payload: Dict[str, Any]) -> bytes: + return json.dumps(payload).encode() + + +@dataclass +class StatusMessage: + status: str + message: Union[str, Dict[str, Any]] = "" + + def to_bytes(self) -> bytes: + return _to_bytes( + { + "type": "status", + "data": { + "status": self.status, + "message": self.message, + }, + } + ) + + +@dataclass +class ModelMessage: + data: Dict[str, Any] + + def to_bytes(self) -> bytes: + return _to_bytes({"type": "msg", "data": self.data}) + + +@dataclass +class NoRefMessage: + data: Dict[str, Any] + + def to_bytes(self) -> bytes: + return _to_bytes({"type": "noreference", "data": self.data}) diff --git a/backend/session_registry.py b/backend/modules/registry.py similarity index 58% rename from backend/session_registry.py rename to backend/modules/registry.py index 4938fed..1584a34 100644 --- a/backend/session_registry.py +++ b/backend/modules/registry.py @@ -1,20 +1,19 @@ from __future__ import annotations import threading -from typing import Dict, Optional, TYPE_CHECKING, Iterable +from typing import Dict, Iterable, Optional -if TYPE_CHECKING: - from server import ModelQuerySession +from .session import ModelQuerySession class SessionRegistry: - """线程安全的会话注册表,用于追踪活跃的生成会话。""" + """线程安全的会话注册表。""" def __init__(self) -> None: - self._sessions: Dict[str, "ModelQuerySession"] = {} + self._sessions: Dict[str, ModelQuerySession] = {} self._lock = threading.Lock() - def register(self, session: "ModelQuerySession") -> None: + def register(self, session: ModelQuerySession) -> None: with self._lock: self._sessions[session.session_id] = session @@ -22,7 +21,7 @@ def remove(self, session_id: str) -> None: with self._lock: self._sessions.pop(session_id, None) - def get(self, session_id: str) -> Optional["ModelQuerySession"]: + def get(self, session_id: str) -> Optional[ModelQuerySession]: with self._lock: return self._sessions.get(session_id) diff --git a/backend/modules/session.py b/backend/modules/session.py new file mode 100644 index 0000000..a2f952b --- /dev/null +++ b/backend/modules/session.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable, Dict, List + +from .exceptions import GenerationCancelled +from .messages import ModelMessage, NoRefMessage, StatusMessage + +logger = logging.getLogger(__name__) + +ResponseWriter = Callable[[bytes], None] +QueryExecutor = Callable[[Dict[str, Any], "ModelQuerySession"], None] + + +class ModelQuerySession: + """封装单次生成流程的上下文与与客户端通信能力。""" + + required_fields = [ + "target_focal_method", + "target_focal_file", + "test_desc", + "project_path", + "focal_file_path", + ] + + def __init__( + self, + session_id: str, + raw_data: Dict[str, Any], + writer: ResponseWriter, + executor: QueryExecutor, + junit_version: int, + ) -> None: + self.session_id = session_id + self.raw_data = raw_data + self._writer = writer + self._executor = executor + self.junit_version = junit_version + + self.messages: List[Dict[str, Any]] = [] + self.query_data = {field: self.raw_data[field] for field in self.required_fields} + self._session_running = False + self._cancel_event = threading.Event() + + def start_query(self) -> None: + if self._session_running: + logger.warning("Session %s already running", self.session_id) + return + self._session_running = True + logger.info("Starting query session %s", self.session_id) + try: + self._executor(self.query_data, self) + except GenerationCancelled: + logger.info("Query session %s cancelled by user", self.session_id) + finally: + self._session_running = False + + def update_messages(self, messages: List[Dict[str, Any]]) -> None: + self.messages = messages + data_to_send = {"session_id": self.session_id, "messages": messages} + self._safe_write(ModelMessage(data_to_send).to_bytes()) + + def write_start_message(self) -> None: + self._safe_write(StatusMessage("start", {"session_id": self.session_id}).to_bytes()) + + def write_noref_message(self) -> None: + payload = {"session_id": self.session_id, "junit_version": self.junit_version} + self._safe_write(NoRefMessage(payload).to_bytes()) + + def write_finish_message(self) -> None: + self._safe_write(StatusMessage("finish", {"session_id": self.session_id}).to_bytes()) + + def request_stop(self) -> None: + self._cancel_event.set() + + def should_stop(self) -> bool: + return self._cancel_event.is_set() + + def _safe_write(self, payload: bytes) -> None: + try: + self._writer(payload) + except BrokenPipeError: + logger.warning("Connection closed for session %s", self.session_id) + self.request_stop() diff --git a/backend/server.py b/backend/server.py index 001e253..384cabf 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,299 +1,194 @@ -import socket -import http.server -import socketserver -import threading -import datetime +from __future__ import annotations + +import argparse import json -from time import strftime -from xml.etree.ElementPath import prepare_child import logging -import sys -import traceback -import argparse -import main -import hashlib -import uuid -from typing import Optional, Union - -from exceptions import GenerationCancelled -from session_registry import SessionRegistry +import socketserver +import threading +from http.server import BaseHTTPRequestHandler +from typing import Any, Dict -port = 8080 +try: + from backend import main as generation_entry_module # when run as package +except ImportError: + import main as generation_entry_module # when invoked from backend directory +from modules.registry import SessionRegistry +from modules.session import ModelQuerySession -# a standard python logger logger = logging.getLogger(__name__) -# basiConfig can only be called once -logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(asctime)s] [%(levelname)s] %(message)s') - -global_junit_version = 4 -session_registry = SessionRegistry() - -class StatusMessage: - def __init__(self, status: str, message: Union[str, dict] = ''): - self.status = status - self.message = message - - def response(self): - return json.dumps({ - "type": "status", - "data": { - "status": self.status, - "message": self.message - } - }).encode() - -class ModelMessage: - def __init__(self, data): - self.data = data - - def response(self): - return json.dumps({ - "type": "msg", - "data": self.data - }).encode() - -class NoRefMessage: - def __init__(self, data): - self.data = data - - def response(self): - return json.dumps({ - "type": "noreference", - "data": self.data - }).encode() - -class QueryHandler(http.server.BaseHTTPRequestHandler): - def do_POST(self): - global global_junit_version - - if self.path == '/session': - - try: - self.request.settimeout(2.0) - query_text_bytes = self.rfile.read(int(self.headers['Content-Length'])) - query_text = query_text_bytes.decode('utf-8') - self.request.settimeout(None) - - try: - query_session = assign_to_session(query_text, self) - except Exception as e: - logger.error(f'Request may be invalid. Message: {e}. Request:\n{query_text}\n{traceback.format_exc()}') - self.end_with_request_error(str(e)) - return - - if query_session: - try: - self.send_keep_alive_header() - query_session.write_start_message() - query_session.start_query() - query_session.write_finish_message() - self.end_session() - finally: - session_registry.remove(query_session.session_id) - else: - raise ValueError("No query session can be constructed or retrieved from request") - - except Exception as e: - logger.error(f'Error handling request. Message: {e}. Request:\n{self.request}\n{traceback.format_exc()}') - self.end_with_internal_error(str(e)) - - elif self.path == '/junitVersion': - try: - self.request.settimeout(2.0) - junit_version = int(json.loads(self.rfile.read(int(self.headers['Content-Length'])).decode('utf-8'))['data']) - self.request.settimeout(None) - - global_junit_version = junit_version - except Exception as e: - logger.error(f"Error handling request. Message: {e}. Request:\n{self.request}\n{traceback.format_exc()}") - self.end_with_internal_error(str(e)) - - elif self.path == '/session/stop': - try: - payload_length = int(self.headers.get('Content-Length', 0)) - request_data = self.rfile.read(payload_length).decode('utf-8') - payload = json.loads(request_data) - session_id = payload.get('session_id') - if not session_id: - self.end_with_request_error('Missing session_id') - return - session = session_registry.get(session_id) - if not session: - self.send_response(404, 'Session Not Found') - self.end_headers() - return - session.request_stop() - self.send_response(200, 'Stopping') - self.end_headers() - except json.JSONDecodeError as e: - logger.error(f'Error decoding stop payload: {e}') - self.end_with_request_error(str(e)) - except Exception as e: - logger.error(f'Failed to stop session: {e}\n{traceback.format_exc()}') - self.end_with_internal_error(str(e)) +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] [%(levelname)s] %(message)s", +) - else: - self.send_response(404) - self.end_headers() +DEFAULT_PORT = 8080 +_global_junit_version = 4 +_session_registry = SessionRegistry() - def send_keep_alive_header(self): - self.send_response(200, 'Success') - self.send_header('Content-type', 'application/json') # this doesn't exist on - self.send_header('Cache-Control', 'no-cache') - self.send_header('Connection', 'keep-alive') - self.end_headers() - - def end_with_error(self, code: int, error_msg: str, concrete_msg: str): - self.send_response(code, error_msg) - self.end_headers() - self.end_session() - # self.wfile.write(StatusMessage('error', concrete_msg).response()) - def end_with_request_error(self, msg: str): - self.end_with_error(400, 'Bad Request', msg) +class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + daemon_threads = True + allow_reuse_address = True - def end_with_internal_error(self, msg: str): - self.end_with_error(500, 'Internal Server Error', msg) - def end_session(self): - self.close_connection = True +class ResponseStream: + """封装 Handler 的写操作,确保线程安全。""" - def write_single_line(self, data: bytes): - self.wfile.write(data + b'\n') - self.wfile.flush() - -class ModelQuerySession: - '''Session persistent data.''' - required_fields = ['target_focal_method', 'target_focal_file', 'test_desc', 'project_path', 'focal_file_path'] - - def __init__(self, session_id: str, raw_data: dict, handler: QueryHandler): - self.session_id = session_id - self.raw_data = raw_data - self.handler = handler - self.messages = [] - self.junit_version = global_junit_version - - self.query_data = self.prepare_query_arguments() - self.session_running = False - self._cancel_event = threading.Event() - - def prepare_query_arguments(self): - # do with session_meta_data - return {x: self.raw_data[x] for x in self.required_fields } - - def start_query(self): - if not self.session_running: - self.session_running = True - logger.info(f'Starting query session {self.session_id}') - try: - main.main(**self.query_data, query_session = self) - except GenerationCancelled: - logger.info(f'Query session {self.session_id} cancelled by user') - finally: - self.session_running = False - - def update_messages(self, messages): - self.messages = messages - data_to_send = { - 'session_id': self.session_id, - 'messages': messages - } - self._safe_write(ModelMessage(data_to_send).response()) - - def write_start_message(self): - data = { - 'session_id': self.session_id - } - self._safe_write(StatusMessage('start', data).response()) - - def write_noref_message(self): - data = { - 'session_id': self.session_id, - 'junit_version': self.junit_version - } - self._safe_write(NoRefMessage(data).response()) - - def write_finish_message(self): - data = { - 'session_id': self.session_id - } - self._safe_write(StatusMessage('finish', data).response()) - - def request_stop(self): - self._cancel_event.set() - - def should_stop(self): - return self._cancel_event.is_set() - - def _safe_write(self, payload: bytes): - try: - self.handler.write_single_line(payload) - except BrokenPipeError: - logger.warning(f'Connection closed for session {self.session_id}') - self.request_stop() - -# Not used now, we still send raw time -def get_hash(s: str): - h = hashlib.sha256(s.encode('utf-8')) - return h.hexdigest() - -def assign_to_session(query_text: str, query_handler: QueryHandler) -> Optional[ModelQuerySession]: - query_data = json.loads(query_text) - request_payload = validate_query_payload(query_data) - - session_id = uuid.uuid4().hex - new_session = ModelQuerySession(session_id, request_payload, query_handler) - session_registry.register(new_session) - return new_session - -def validate_query_payload(payload: dict) -> dict: - if payload.get('type') != 'query': - raise ValueError('Unsupported request type') - data = payload.get('data') + def __init__(self, handler: BaseHTTPRequestHandler) -> None: + self._handler = handler + self._lock = threading.Lock() + + def __call__(self, data: bytes) -> None: + with self._lock: + self._handler.wfile.write(data + b"\n") + self._handler.wfile.flush() + + +def run_generation(query_data: Dict[str, Any], session: ModelQuerySession) -> None: + generation_entry_module.main(**query_data, query_session=session) + + +def build_session(payload: Dict[str, Any], handler: BaseHTTPRequestHandler) -> ModelQuerySession: + session_id = payload["session_id"] + response_stream = ResponseStream(handler) + return ModelQuerySession( + session_id=session_id, + raw_data=payload["data"], + writer=response_stream, + executor=run_generation, + junit_version=_global_junit_version, + ) + + +def validate_query_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + if payload.get("type") != "query": + raise ValueError("Unsupported request type") + data = payload.get("data") if not isinstance(data, dict): - raise ValueError('Query data must be a JSON object') + raise ValueError("Query data must be a JSON object") missing = [field for field in ModelQuerySession.required_fields if field not in data] if missing: raise ValueError(f"Missing required fields: {', '.join(missing)}") - return data + return {"session_id": payload.get("session_id") or payload.get("id") or handler_uuid(), "data": data} -# def find_open_port(): -# with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: -# s.bind(('', 0)) # Bind to any available port -# return s.getsockname()[1] # Return the port number -class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - daemon_threads = True - allow_reuse_address = True +def handler_uuid() -> str: + import uuid + + return uuid.uuid4().hex + + +class QueryHandler(BaseHTTPRequestHandler): + server_version = "IntentionTestHTTP/1.0" + + def do_POST(self) -> None: # noqa: N802 + if self.path == "/session": + self._handle_session_request() + elif self.path == "/session/stop": + self._handle_stop_request() + elif self.path == "/junitVersion": + self._handle_junit_version() + else: + self.send_response(404) + self.end_headers() + + def _handle_session_request(self) -> None: + try: + payload = self._read_json_body() + request_payload = validate_query_payload(payload) + session = build_session(request_payload, self) + except Exception as exc: # broad catch to surface payload issues + logger.error("Invalid session request: %s", exc, exc_info=True) + self._end_with_error(400, "Bad Request", str(exc)) + return + + try: + _session_registry.register(session) + self._send_keep_alive_header() + session.write_start_message() + session.start_query() + session.write_finish_message() + except Exception as exc: + logger.error("Error processing session: %s", exc, exc_info=True) + self._end_with_error(500, "Internal Server Error", str(exc)) + finally: + _session_registry.remove(session.session_id) + self._end_session() + + def _handle_stop_request(self) -> None: + try: + payload = self._read_json_body() + session_id = payload.get("session_id") + if not session_id: + raise ValueError("Missing session_id") + session = _session_registry.get(session_id) + if not session: + self.send_response(404, "Session Not Found") + self.end_headers() + return + session.request_stop() + self.send_response(200, "Stopping") + self.end_headers() + except ValueError as exc: + self._end_with_error(400, "Bad Request", str(exc)) + except Exception as exc: + logger.error("Failed to stop session: %s", exc, exc_info=True) + self._end_with_error(500, "Internal Server Error", str(exc)) -def start_http_server(port: int): - logger.info(f'Starting HTTP server on port {port}') + def _handle_junit_version(self) -> None: + global _global_junit_version + try: + payload = self._read_json_body() + version = int(payload["data"]) + except Exception as exc: + self._end_with_error(400, "Bad Request", f"Invalid payload: {exc}") + return + + _global_junit_version = version + self.send_response(200, "Success") + self.end_headers() + + def _send_keep_alive_header(self) -> None: + self.send_response(200, "Success") + self.send_header("Content-type", "application/json") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + + def _end_with_error(self, code: int, error_msg: str, _: str) -> None: + self.send_response(code, error_msg) + self.end_headers() + self._end_session() + + def _end_session(self) -> None: + self.close_connection = True + + def _read_json_body(self) -> Dict[str, Any]: + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length).decode("utf-8") + return json.loads(body) if body else {} + + +def start_http_server(port: int) -> None: + logger.info("Starting HTTP server on port %s", port) httpd = ThreadedTCPServer(("", port), QueryHandler) - port = httpd.server_address[1] - logger.info(f'HTTP server is started and listening on {port}') - - th = threading.Thread(target=httpd.serve_forever, daemon=True) - th.start() - th.join() - -# class StdioServer: -# def __init__(self): -# self.handler = QueryHandler() -# self.should_run = True - -# def serve_forever(self): -# while self.should_run: -# request = sys.stdin.read() -# if request: -# self.handler.handle_one_request() - -# def shutdown(self): -# self.should_run = False - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Start the model server') - parser.add_argument('--port', type=int, default=8080, help='Port to start the server on') # by default listen to a random port - + actual_port = httpd.server_address[1] + logger.info("HTTP server is listening on %s", actual_port) + try: + httpd.serve_forever() + except KeyboardInterrupt: + logger.info("Shutting down HTTP server") + finally: + httpd.server_close() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Start the model server") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to start the server on") args = parser.parse_args() start_http_server(args.port) + + +if __name__ == "__main__": + main() From 670dab8c119998b68c481ae15394b864185b0991 Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:31:20 +0800 Subject: [PATCH 10/13] feat(UI update) --- web/index.html | 6 ++ web/index.js | 163 ++++++++++++++++++++++++++----------------------- web/styles.css | 132 ++++++++++++++++++++++++++------------- 3 files changed, 184 insertions(+), 117 deletions(-) diff --git a/web/index.html b/web/index.html index dfbe152..8d84e42 100644 --- a/web/index.html +++ b/web/index.html @@ -15,7 +15,13 @@ Intention Test LLM-based iterative test assistant
+
+ + Idle +
+ +
diff --git a/web/index.js b/web/index.js index 611bb57..05fad98 100644 --- a/web/index.js +++ b/web/index.js @@ -1,7 +1,9 @@ const noMessagePrompt = document.getElementById('no-message'); const chatContainer = document.getElementById('chat-container'); const toolbar = document.getElementById('toolbar'); +const statusPill = document.querySelector('.status-pill'); const body = document.body; +const SCROLL_IDLE_WINDOW_MS = 3000; const defaultNoMessageMarkup = noMessagePrompt.innerHTML; const waitingNoMessageMarkup = `

Intention Test 🧪

@@ -14,21 +16,24 @@ const SessionState = { STOPPED: 'stopped' }; -const OPEN_CODE_ICON = ''; -const RESTART_ICON = ''; - let messageCount = 0; let lastUserScrollTime = Date.now(); let sessionState = SessionState.IDLE; +let scrollLockEnabled = false; const canConnectToVsCode = typeof acquireVsCodeApi === 'function'; if (canConnectToVsCode) { window.vscode = acquireVsCodeApi(); } +// 初始化时将 lastUserScrollTime 设为过去,方便首条消息也能自动滚动 +lastUserScrollTime = Date.now() - SCROLL_IDLE_WINDOW_MS - 100; + const toolbarHandlers = { 'clear-chat': requestClearConversation, - 'stop-run': requestStopConversation + 'stop-run': requestStopConversation, + 'jump-latest': scrollToLatest, + 'toggle-scroll-lock': toggleScrollLock }; toolbar?.querySelectorAll('[data-action]').forEach((button) => { @@ -39,25 +44,35 @@ toolbar?.querySelectorAll('[data-action]').forEach((button) => { } }); updateToolbarState(); +reflectStatusPill(); updatePlaceholderVisibility(); -body.addEventListener('wheel', () => { +const updateLastScrollTime = () => { lastUserScrollTime = Date.now(); -}, { passive: true }); +}; -body.addEventListener('mousedown', () => { - lastUserScrollTime = Date.now(); -}); +window.addEventListener('wheel', updateLastScrollTime, { passive: true }); +window.addEventListener('mousedown', updateLastScrollTime); +window.addEventListener('scroll', updateLastScrollTime, { passive: true }); +window.addEventListener('touchstart', updateLastScrollTime, { passive: true }); + +window.addEventListener('message', handleIncomingMessage); function scrollToLatest() { - body.scrollTo({ - top: body.scrollHeight, + window.scrollTo({ + top: document.documentElement.scrollHeight, behavior: 'smooth' }); } function maybeAutoScroll() { - if (Date.now() - lastUserScrollTime > 3000) { + if (scrollLockEnabled) { + return; + } + const nearBottom = + window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 80; + const userIdle = Date.now() - lastUserScrollTime > SCROLL_IDLE_WINDOW_MS; + if (nearBottom || userIdle) { scrollToLatest(); } } @@ -90,6 +105,17 @@ function requestStopConversation() { window.vscode?.postMessage({ cmd: 'stop-run' }); } +function toggleScrollLock() { + scrollLockEnabled = !scrollLockEnabled; + updateToolbarState(); + if (scrollLockEnabled) { + addSystemNotice('已锁定阅读,停止自动滚动。'); + } else { + addSystemNotice('已解除锁定,将自动滚动到最新。'); + maybeAutoScroll(); + } +} + function setSessionState(nextState) { if (!nextState) { return; @@ -100,6 +126,7 @@ function setSessionState(nextState) { } updateToolbarState(); updatePlaceholderVisibility(); + reflectStatusPill(); } function updateToolbarState() { @@ -113,6 +140,12 @@ function updateToolbarState() { stopButton.disabled = !canStop; stopButton.textContent = isStopping ? '停止中…' : '停止'; } + + const scrollLockButton = toolbar.querySelector('[data-action="toggle-scroll-lock"]'); + if (scrollLockButton) { + scrollLockButton.dataset.locked = String(scrollLockEnabled); + scrollLockButton.textContent = scrollLockEnabled ? '解除锁定' : '阅读锁定'; + } } function updatePlaceholderVisibility() { @@ -133,6 +166,29 @@ function addSystemNotice(message) { addMessage(message, 'system', { senderType: 'system' }); } +function reflectStatusPill() { + if (!statusPill) { + return; + } + let text = 'Idle'; + let stateAttr = 'idle'; + if (sessionState === SessionState.RUNNING) { + text = 'Running'; + stateAttr = 'running'; + } else if (sessionState === SessionState.STOPPING) { + text = 'Stopping…'; + stateAttr = 'stopping'; + } else if (sessionState === SessionState.STOPPED) { + text = 'Stopped'; + stateAttr = 'stopped'; + } + statusPill.dataset.state = stateAttr; + const textNode = statusPill.querySelector('.status-text'); + if (textNode) { + textNode.textContent = text; + } +} + function createMessageContent(message, isHtml) { const messageContentElement = document.createElement('div'); messageContentElement.className = 'message-content'; @@ -191,9 +247,7 @@ function addMessage(message, sender, options = {}) { function enhanceMessageElement(messageElement) { const codeBlocks = messageElement.querySelectorAll('pre code'); codeBlocks.forEach((block) => { - block.querySelectorAll('.open-code-button, .restart-from-here-button').forEach((btn) => btn.remove()); hljs.highlightElement(block); - attachCodeBlockActions(block, messageElement); }); messageElement.querySelectorAll('code').forEach((inlineCode) => { @@ -203,52 +257,6 @@ function enhanceMessageElement(messageElement) { }); } -function attachCodeBlockActions(block, messageElement) { - const openButton = createIconButton('open-code-button', 'Open', OPEN_CODE_ICON); - openButton.onclick = (event) => { - event.stopPropagation(); - const lang = detectLanguage(block); - window.vscode?.postMessage({ cmd: 'open-code', content: block.textContent, lang }); - }; - - const restartButton = createIconButton('restart-from-here-button', 'Restart with this', RESTART_ICON); - restartButton.onclick = (event) => { - event.stopPropagation(); - if (typeof messageElement.index === 'number') { - window.vscode?.postMessage({ cmd: 'restart-session', number: messageElement.index }); - } - }; - - block.appendChild(openButton); - block.appendChild(restartButton); - - block.onmouseenter = () => toggleActionButtons(block, true); - block.onmouseleave = () => toggleActionButtons(block, false); -} - -function createIconButton(className, title, icon) { - const button = document.createElement('button'); - button.className = className; - button.title = title; - button.innerHTML = icon; - return button; -} - -function toggleActionButtons(block, visible) { - block.querySelectorAll('.open-code-button, .restart-from-here-button').forEach((button) => { - button.classList.toggle('show', visible); - }); -} - -function detectLanguage(block) { - for (const cls of block.classList) { - if (cls.startsWith('language-')) { - return cls.substring('language-'.length); - } - } - return undefined; -} - function showTypingAnimation(sender) { addMessage('
', sender, { isHtml: true, @@ -293,7 +301,7 @@ function removeTypingAnimation() { } } -window.addEventListener('message', (event) => { +function handleIncomingMessage(event) { const msg = event.data; if (msg?.role && msg?.content) { noMessagePrompt.style.display = 'none'; @@ -319,19 +327,24 @@ window.addEventListener('message', (event) => { if (messageElement) { messageCount += 1; } - } else if (msg?.cmd) { - if (msg.cmd === 'session-state') { - const nextState = msg.state ?? SessionState.IDLE; - setSessionState(nextState); - if (typeof msg.message === 'string' && msg.message.trim().length > 0) { - addSystemNotice(msg.message); - } else if (nextState === SessionState.STOPPED) { - addSystemNotice('生成已停止,不再继续。'); - } - } else if (msg.cmd === 'error') { - console.error('[IntentionTest] Webview error message received:', msg); - } else if (msg.cmd === 'clear') { - trimConversationTo(msg.toIndex ?? 0); + return; + } + + if (!msg?.cmd) { + return; + } + + if (msg.cmd === 'session-state') { + const nextState = msg.state ?? SessionState.IDLE; + setSessionState(nextState); + if (typeof msg.message === 'string' && msg.message.trim().length > 0) { + addSystemNotice(msg.message); + } else if (nextState === SessionState.STOPPED) { + addSystemNotice('生成已停止,不再继续。'); } + } else if (msg.cmd === 'error') { + console.error('[IntentionTest] Webview error message received:', msg); + } else if (msg.cmd === 'clear') { + trimConversationTo(msg.toIndex ?? 0); } -}); +} diff --git a/web/styles.css b/web/styles.css index 5d5bb42..a56a017 100644 --- a/web/styles.css +++ b/web/styles.css @@ -3,6 +3,7 @@ --surface-card: var(--vscode-editorWidget-background, #ffffff); --surface-muted: var(--vscode-editor-inactiveSelectionBackground, rgba(0, 0, 0, 0.05)); --border-color: var(--vscode-editorWidget-border, rgba(0, 0, 0, 0.08)); + --border-strong: color-mix(in srgb, var(--border-color) 70%, transparent); --assistant-accent: var(--vscode-testing-iconPassed, #2ea043); --user-accent: var(--vscode-testing-iconQueued, #007acc); --code-block-bg: var(--vscode-editor-background, #1e1e1e); @@ -10,6 +11,12 @@ --code-block-border: var(--vscode-editor-lineHighlightBorder, rgba(255, 255, 255, 0.08)); --link-color: var(--vscode-textLink-foreground, #3794ff); --toolbar-bg: var(--vscode-sideBarSectionHeader-background, rgba(255, 255, 255, 0.2)); + --hint-text: var(--vscode-descriptionForeground, rgba(0, 0, 0, 0.6)); + --danger: var(--vscode-charts-red, #f85149); + --warn: var(--vscode-charts-orange, #ffab70); + --ok: var(--vscode-testing-iconPassed, #2ea043); + --idle: var(--vscode-foreground, #4b5563); + --grid-line: color-mix(in srgb, var(--border-color) 60%, transparent); } html { @@ -23,7 +30,10 @@ body { flex-direction: column; min-height: 100vh; overflow-y: auto; - background-color: var(--surface-background); + background: + radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--surface-muted) 55%, transparent), transparent 35%), + radial-gradient(circle at 80% 0%, color-mix(in srgb, var(--surface-muted) 45%, transparent), transparent 32%), + linear-gradient(120deg, color-mix(in srgb, var(--surface-background) 92%, transparent), var(--surface-background)); color: var(--vscode-sideBar-foreground, #1e1e1e); font-family: var(--vscode-font-family, "Segoe UI", "Helvetica Neue", Arial, sans-serif); gap: 16px; @@ -45,9 +55,14 @@ body { padding: 20px; background-color: var(--surface-card); border-radius: 16px; - border: 1px solid var(--border-color); - box-shadow: 0 14px 30px rgba(0, 0, 0, 0.08); + border: 1px solid var(--border-strong); + box-shadow: + 0 14px 30px rgba(0, 0, 0, 0.08), + inset 0 0 0 1px color-mix(in srgb, var(--border-color) 40%, transparent); box-sizing: border-box; + background-image: linear-gradient(90deg, transparent 0, transparent 95%, var(--grid-line) 95%), + linear-gradient(180deg, transparent 0, transparent 95%, var(--grid-line) 95%); + background-size: 16px 16px; } #no-message { @@ -161,12 +176,13 @@ body { background-color: var(--code-block-bg); color: var(--code-block-foreground); border-radius: 12px; - padding: 16px 48px 16px 16px; + padding: 16px; margin: 0.6em 0; overflow-x: auto; border: 1px solid var(--code-block-border); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); box-sizing: border-box; + width: 100%; max-width: 100%; } @@ -174,6 +190,19 @@ body { display: block; width: 100%; box-sizing: border-box; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +.message pre.code-wrap-off code { + white-space: pre; + word-break: normal; + overflow-wrap: normal; +} + +.message pre.code-wrap-off { + overflow-x: auto; } .message a { color: var(--link-color); @@ -215,43 +244,6 @@ body { animation-delay: 0.4s; } -.open-code-button, -.restart-from-here-button { - position: absolute; - top: 8px; - width: 28px; - height: 28px; - padding: 2px; - opacity: 0.5; - visibility: hidden; - border-radius: 6px; - border: 1px solid var(--border-color); - background-color: rgba(0, 0, 0, 0.04); - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; -} - -.open-code-button { - right: 8px; -} - -.restart-from-here-button { - right: 44px; -} - -.open-code-button.show, -.restart-from-here-button.show { - visibility: visible; -} - -.open-code-button.show:hover, -.restart-from-here-button.show:hover { - opacity: 1.0; - background-color: rgba(0, 0, 0, 0.08); -} - .hljs { background: transparent; color: var(--code-block-foreground); @@ -320,6 +312,7 @@ body { display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; background-color: var(--toolbar-bg); border: 1px solid var(--border-color); border-radius: 16px; @@ -327,6 +320,7 @@ body { margin-bottom: 12px; gap: 12px; backdrop-filter: blur(12px); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); } #toolbar .title { @@ -342,12 +336,14 @@ body { #toolbar .tagline { font-size: 0.85rem; - color: var(--vscode-descriptionForeground, rgba(0, 0, 0, 0.6)); + color: var(--hint-text); } .toolbar-actions { display: flex; gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; } .toolbar-actions button { @@ -359,6 +355,9 @@ body { font-weight: 600; cursor: pointer; transition: transform 0.15s ease, box-shadow 0.15s ease; + display: inline-flex; + align-items: center; + gap: 6px; } .toolbar-actions button:hover { @@ -382,3 +381,52 @@ body { background-image: linear-gradient(135deg, rgba(255, 99, 72, 0.15), transparent); border-color: rgba(255, 99, 72, 0.3); } + +.status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--surface-muted) 65%, transparent); + border: 1px solid var(--border-color); + font-weight: 600; + color: var(--idle); +} + +.status-pill .status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--idle); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--idle) 20%, transparent); +} + +.status-pill[data-state="running"] { + color: var(--ok); +} + +.status-pill[data-state="running"] .status-dot { + background: var(--ok); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 25%, transparent); +} + +.status-pill[data-state="stopping"], +.status-pill[data-state="stopped"] { + color: var(--warn); +} + +.status-pill[data-state="stopping"] .status-dot, +.status-pill[data-state="stopped"] .status-dot { + background: var(--warn); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--warn) 30%, transparent); +} + +.status-pill[data-state="error"] { + color: var(--danger); +} + +.status-pill[data-state="error"] .status-dot { + background: var(--danger); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 30%, transparent); +} From bc64e5079e3920e43aa1a3802eb6e06f15b8e0c3 Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:31:57 +0800 Subject: [PATCH 11/13] feat(UI): Simplify UI display according to the comments --- web/styles.css | 75 +++++++++++++++++--------------------------------- 1 file changed, 25 insertions(+), 50 deletions(-) diff --git a/web/styles.css b/web/styles.css index a56a017..4061d15 100644 --- a/web/styles.css +++ b/web/styles.css @@ -2,13 +2,12 @@ --surface-background: var(--vscode-sideBar-background, #f5f5f5); --surface-card: var(--vscode-editorWidget-background, #ffffff); --surface-muted: var(--vscode-editor-inactiveSelectionBackground, rgba(0, 0, 0, 0.05)); - --border-color: var(--vscode-editorWidget-border, rgba(0, 0, 0, 0.08)); - --border-strong: color-mix(in srgb, var(--border-color) 70%, transparent); + --border-color: var(--vscode-editorWidget-border, rgba(0, 0, 0, 0.12)); --assistant-accent: var(--vscode-testing-iconPassed, #2ea043); --user-accent: var(--vscode-testing-iconQueued, #007acc); - --code-block-bg: var(--vscode-editor-background, #1e1e1e); - --code-block-foreground: var(--vscode-editor-foreground, #d4d4d4); - --code-block-border: var(--vscode-editor-lineHighlightBorder, rgba(255, 255, 255, 0.08)); + --code-block-bg: var(--vscode-editor-background, #f3f4f6); + --code-block-foreground: var(--vscode-editor-foreground, #111827); + --code-block-border: var(--border-color); --link-color: var(--vscode-textLink-foreground, #3794ff); --toolbar-bg: var(--vscode-sideBarSectionHeader-background, rgba(255, 255, 255, 0.2)); --hint-text: var(--vscode-descriptionForeground, rgba(0, 0, 0, 0.6)); @@ -16,7 +15,6 @@ --warn: var(--vscode-charts-orange, #ffab70); --ok: var(--vscode-testing-iconPassed, #2ea043); --idle: var(--vscode-foreground, #4b5563); - --grid-line: color-mix(in srgb, var(--border-color) 60%, transparent); } html { @@ -30,10 +28,7 @@ body { flex-direction: column; min-height: 100vh; overflow-y: auto; - background: - radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--surface-muted) 55%, transparent), transparent 35%), - radial-gradient(circle at 80% 0%, color-mix(in srgb, var(--surface-muted) 45%, transparent), transparent 32%), - linear-gradient(120deg, color-mix(in srgb, var(--surface-background) 92%, transparent), var(--surface-background)); + background: var(--surface-background); color: var(--vscode-sideBar-foreground, #1e1e1e); font-family: var(--vscode-font-family, "Segoe UI", "Helvetica Neue", Arial, sans-serif); gap: 16px; @@ -52,26 +47,16 @@ body { display: flex; flex-direction: column; gap: 12px; - padding: 20px; - background-color: var(--surface-card); - border-radius: 16px; - border: 1px solid var(--border-strong); - box-shadow: - 0 14px 30px rgba(0, 0, 0, 0.08), - inset 0 0 0 1px color-mix(in srgb, var(--border-color) 40%, transparent); + padding: 4px 2px; box-sizing: border-box; - background-image: linear-gradient(90deg, transparent 0, transparent 95%, var(--grid-line) 95%), - linear-gradient(180deg, transparent 0, transparent 95%, var(--grid-line) 95%); - background-size: 16px 16px; } #no-message { background-color: var(--surface-card); border: 1px dashed var(--border-color); - border-radius: 16px; - padding: 32px; + border-radius: 12px; + padding: 24px; text-align: center; - box-shadow: inset 0 0 0 1px var(--surface-muted); } #no-message h1 { @@ -85,16 +70,14 @@ body { } .message { - margin: 0 0 24px 0; - border-radius: 12px; - transition: all 0.25s ease-in-out; - overflow: hidden; + margin: 0 0 16px 0; + border-radius: 10px; + transition: background-color 0.2s ease-in-out; width: 100%; max-width: 100%; background-color: var(--surface-card); border: 1px solid var(--border-color); - padding: 12px 16px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); + padding: 12px 14px; box-sizing: border-box; word-break: break-word; } @@ -106,15 +89,13 @@ body { } .message.assistant { - border-left: 4px solid var(--assistant-accent); - background-image: linear-gradient(135deg, rgba(46, 160, 67, 0.08), transparent); + border-left: 3px solid var(--assistant-accent); margin-right: auto; } .message.user, .message.system { - background-image: linear-gradient(135deg, rgba(0, 122, 204, 0.08), transparent); - border-left: 4px solid var(--user-accent); + border-left: 3px solid var(--user-accent); margin-right: auto; } @@ -175,12 +156,11 @@ body { position: relative; background-color: var(--code-block-bg); color: var(--code-block-foreground); - border-radius: 12px; - padding: 16px; + border-radius: 10px; + padding: 12px 14px; margin: 0.6em 0; overflow-x: auto; border: 1px solid var(--code-block-border); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); box-sizing: border-box; width: 100%; max-width: 100%; @@ -210,11 +190,9 @@ body { .message-header { font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + font-weight: 600; color: var(--vscode-descriptionForeground, rgba(0, 0, 0, 0.55)); - margin: 4px 0 6px; + margin: 0 0 6px; } .typing { @@ -222,7 +200,7 @@ body { align-items: center; gap: 6px; background-color: var(--surface-muted); - padding: 12px; + padding: 10px 12px; border-radius: 8px; } @@ -315,12 +293,10 @@ body { flex-wrap: wrap; background-color: var(--toolbar-bg); border: 1px solid var(--border-color); - border-radius: 16px; - padding: 16px 20px; - margin-bottom: 12px; + border-radius: 12px; + padding: 12px 16px; + margin-bottom: 8px; gap: 12px; - backdrop-filter: blur(12px); - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); } #toolbar .title { @@ -349,20 +325,19 @@ body { .toolbar-actions button { border: 1px solid var(--border-color); border-radius: 999px; - padding: 8px 14px; + padding: 6px 12px; background-color: var(--surface-card); color: inherit; font-weight: 600; cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: background-color 0.15s ease; display: inline-flex; align-items: center; gap: 6px; } .toolbar-actions button:hover { - transform: translateY(-1px); - box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08); + background-color: var(--surface-muted); } .toolbar-actions button:disabled { From c3593f8d81abef18d01b3a46b31ac2c8e5d5819b Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 17:32:53 +0800 Subject: [PATCH 12/13] feat(UI): add the code copy function --- web/index.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ web/styles.css | 20 ++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/web/index.js b/web/index.js index 05fad98..bdfa0f0 100644 --- a/web/index.js +++ b/web/index.js @@ -248,6 +248,22 @@ function enhanceMessageElement(messageElement) { const codeBlocks = messageElement.querySelectorAll('pre code'); codeBlocks.forEach((block) => { hljs.highlightElement(block); + const pre = block.parentElement; + if (pre && !pre.querySelector('.code-copy-button')) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'code-copy-button'; + button.textContent = '复制'; + button.addEventListener('click', async () => { + const codeText = block.textContent ?? ''; + const copied = await copyToClipboard(codeText); + button.textContent = copied ? '已复制' : '复制失败'; + setTimeout(() => { + button.textContent = '复制'; + }, 1200); + }); + pre.appendChild(button); + } }); messageElement.querySelectorAll('code').forEach((inlineCode) => { @@ -257,6 +273,36 @@ function enhanceMessageElement(messageElement) { }); } +async function copyToClipboard(text) { + if (!text) { + return false; + } + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (error) { + console.warn('[IntentionTest] Clipboard write failed:', error); + } + } + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + let success = false; + try { + success = document.execCommand('copy'); + } catch (error) { + console.warn('[IntentionTest] execCommand copy failed:', error); + success = false; + } + document.body.removeChild(textarea); + return success; +} + function showTypingAnimation(sender) { addMessage('
', sender, { isHtml: true, diff --git a/web/styles.css b/web/styles.css index 4061d15..c2e1b9a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -166,6 +166,26 @@ body { max-width: 100%; } +.message pre .code-copy-button { + position: absolute; + top: 8px; + right: 8px; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 4px 8px; + font-size: 0.75rem; + font-weight: 600; + background: var(--surface-card); + color: inherit; + cursor: pointer; + opacity: 0.8; +} + +.message pre .code-copy-button:hover { + opacity: 1; + background: var(--surface-muted); +} + .message pre code { display: block; width: 100%; From 29826249a1df6ec01a06bb9317be85d98be2387d Mon Sep 17 00:00:00 2001 From: Lyican <1260147616@qq.com> Date: Sat, 3 Jan 2026 19:02:22 +0800 Subject: [PATCH 13/13] fix(backend): fix an unsuitable import --- backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index 0df7e37..9437c2a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,7 +5,7 @@ from generator import IntentionTester from dataset import Dataset from configs import Configs -from server import ModelQuerySession +from modules.session import ModelQuerySession from typing import Optional import pathlib from extension_api.collect_pairs.main import dump_collect_pairs