1391 lines
43 KiB
JavaScript
1391 lines
43 KiB
JavaScript
import { html, css, LitElement } from '../../ui/assets/lit-core-2.7.4.min.js';
|
|
|
|
export class AskView extends LitElement {
|
|
static properties = {
|
|
currentResponse: { type: String },
|
|
currentQuestion: { type: String },
|
|
isLoading: { type: Boolean },
|
|
copyState: { type: String },
|
|
isHovering: { type: Boolean },
|
|
hoveredLineIndex: { type: Number },
|
|
lineCopyState: { type: Object },
|
|
showTextInput: { type: Boolean },
|
|
headerText: { type: String },
|
|
headerAnimating: { type: Boolean },
|
|
isStreaming: { type: Boolean },
|
|
};
|
|
|
|
static styles = css`
|
|
:host {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
color: white;
|
|
transform: translate3d(0, 0, 0);
|
|
backface-visibility: hidden;
|
|
transition: transform 0.2s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.2s ease-out;
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
:host(.hiding) {
|
|
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.6, 1) forwards;
|
|
}
|
|
|
|
:host(.showing) {
|
|
animation: slideDown 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
|
}
|
|
|
|
:host(.hidden) {
|
|
opacity: 0;
|
|
transform: translateY(-150%) scale(0.85);
|
|
pointer-events: none;
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
0% {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
filter: blur(0px);
|
|
}
|
|
30% {
|
|
opacity: 0.7;
|
|
transform: translateY(-20%) scale(0.98);
|
|
filter: blur(0.5px);
|
|
}
|
|
70% {
|
|
opacity: 0.3;
|
|
transform: translateY(-80%) scale(0.92);
|
|
filter: blur(1.5px);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translateY(-150%) scale(0.85);
|
|
filter: blur(2px);
|
|
}
|
|
}
|
|
|
|
@keyframes slideDown {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateY(-150%) scale(0.85);
|
|
filter: blur(2px);
|
|
}
|
|
30% {
|
|
opacity: 0.5;
|
|
transform: translateY(-50%) scale(0.92);
|
|
filter: blur(1px);
|
|
}
|
|
65% {
|
|
opacity: 0.9;
|
|
transform: translateY(-5%) scale(0.99);
|
|
filter: blur(0.2px);
|
|
}
|
|
85% {
|
|
opacity: 0.98;
|
|
transform: translateY(2%) scale(1.005);
|
|
filter: blur(0px);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
filter: blur(0px);
|
|
}
|
|
}
|
|
|
|
* {
|
|
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
cursor: default;
|
|
user-select: none;
|
|
}
|
|
|
|
/* Allow text selection in assistant responses */
|
|
.response-container, .response-container * {
|
|
user-select: text !important;
|
|
cursor: text !important;
|
|
}
|
|
|
|
.response-container pre {
|
|
background: rgba(0, 0, 0, 0.4) !important;
|
|
border-radius: 8px !important;
|
|
padding: 12px !important;
|
|
margin: 8px 0 !important;
|
|
overflow-x: auto !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
|
white-space: pre !important;
|
|
word-wrap: normal !important;
|
|
word-break: normal !important;
|
|
}
|
|
|
|
.response-container code {
|
|
font-family: 'Monaco', 'Menlo', 'Consolas', monospace !important;
|
|
font-size: 11px !important;
|
|
background: transparent !important;
|
|
white-space: pre !important;
|
|
word-wrap: normal !important;
|
|
word-break: normal !important;
|
|
}
|
|
|
|
.response-container pre code {
|
|
white-space: pre !important;
|
|
word-wrap: normal !important;
|
|
word-break: normal !important;
|
|
display: block !important;
|
|
}
|
|
|
|
.response-container p code {
|
|
background: rgba(255, 255, 255, 0.1) !important;
|
|
padding: 2px 4px !important;
|
|
border-radius: 3px !important;
|
|
color: #ffd700 !important;
|
|
}
|
|
|
|
.hljs-keyword {
|
|
color: #ff79c6 !important;
|
|
}
|
|
.hljs-string {
|
|
color: #f1fa8c !important;
|
|
}
|
|
.hljs-comment {
|
|
color: #6272a4 !important;
|
|
}
|
|
.hljs-number {
|
|
color: #bd93f9 !important;
|
|
}
|
|
.hljs-function {
|
|
color: #50fa7b !important;
|
|
}
|
|
.hljs-variable {
|
|
color: #8be9fd !important;
|
|
}
|
|
.hljs-built_in {
|
|
color: #ffb86c !important;
|
|
}
|
|
.hljs-title {
|
|
color: #50fa7b !important;
|
|
}
|
|
.hljs-attr {
|
|
color: #50fa7b !important;
|
|
}
|
|
.hljs-tag {
|
|
color: #ff79c6 !important;
|
|
}
|
|
|
|
.ask-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
width: 100%;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
border-radius: 12px;
|
|
outline: 0.5px rgba(255, 255, 255, 0.3) solid;
|
|
outline-offset: -1px;
|
|
backdrop-filter: blur(1px);
|
|
box-sizing: border-box;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.ask-container::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.15);
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
border-radius: 12px;
|
|
filter: blur(10px);
|
|
z-index: -1;
|
|
}
|
|
|
|
.response-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
background: transparent;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.response-header.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.response-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
background: rgba(255, 255, 255, 0.2);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.response-icon svg {
|
|
width: 12px;
|
|
height: 12px;
|
|
stroke: rgba(255, 255, 255, 0.9);
|
|
}
|
|
|
|
.response-label {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
white-space: nowrap;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.response-label.animating {
|
|
animation: fadeInOut 0.3s ease-in-out;
|
|
}
|
|
|
|
@keyframes fadeInOut {
|
|
0% {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
50% {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.question-text {
|
|
font-size: 13px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 300px;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.header-controls {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.copy-button {
|
|
background: transparent;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
padding: 4px;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 24px;
|
|
height: 24px;
|
|
flex-shrink: 0;
|
|
transition: background-color 0.15s ease;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.copy-button:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.copy-button svg {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
|
}
|
|
|
|
.copy-button .check-icon {
|
|
opacity: 0;
|
|
transform: translate(-50%, -50%) scale(0.5);
|
|
}
|
|
|
|
.copy-button.copied .copy-icon {
|
|
opacity: 0;
|
|
transform: translate(-50%, -50%) scale(0.5);
|
|
}
|
|
|
|
.copy-button.copied .check-icon {
|
|
opacity: 1;
|
|
transform: translate(-50%, -50%) scale(1);
|
|
}
|
|
|
|
.close-button {
|
|
background: rgba(255, 255, 255, 0.07);
|
|
color: white;
|
|
border: none;
|
|
padding: 4px;
|
|
border-radius: 20px;
|
|
outline: 1px rgba(255, 255, 255, 0.3) solid;
|
|
outline-offset: -1px;
|
|
backdrop-filter: blur(0.5px);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.close-button:hover {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: rgba(255, 255, 255, 1);
|
|
}
|
|
|
|
.response-container {
|
|
flex: 1;
|
|
padding: 16px;
|
|
padding-left: 48px;
|
|
overflow-y: auto;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
background: transparent;
|
|
min-height: 0;
|
|
max-height: 400px;
|
|
position: relative;
|
|
}
|
|
|
|
.response-container.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.response-container::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.response-container::-webkit-scrollbar-track {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.response-container::-webkit-scrollbar-thumb {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.response-container::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.loading-dots {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 40px;
|
|
}
|
|
|
|
.loading-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
background: rgba(255, 255, 255, 0.6);
|
|
border-radius: 50%;
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.loading-dot:nth-child(1) {
|
|
animation-delay: 0s;
|
|
}
|
|
|
|
.loading-dot:nth-child(2) {
|
|
animation-delay: 0.2s;
|
|
}
|
|
|
|
.loading-dot:nth-child(3) {
|
|
animation-delay: 0.4s;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%,
|
|
80%,
|
|
100% {
|
|
opacity: 0.3;
|
|
transform: scale(0.8);
|
|
}
|
|
40% {
|
|
opacity: 1;
|
|
transform: scale(1.2);
|
|
}
|
|
}
|
|
|
|
.response-line {
|
|
position: relative;
|
|
padding: 2px 0;
|
|
margin: 0;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.response-line:hover {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.line-copy-button {
|
|
position: absolute;
|
|
left: -32px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
border-radius: 3px;
|
|
padding: 2px;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity 0.15s ease, background-color 0.15s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.response-line:hover .line-copy-button {
|
|
opacity: 1;
|
|
}
|
|
|
|
.line-copy-button:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.line-copy-button.copied {
|
|
background: rgba(40, 167, 69, 0.3);
|
|
}
|
|
|
|
.line-copy-button svg {
|
|
width: 12px;
|
|
height: 12px;
|
|
stroke: rgba(255, 255, 255, 0.9);
|
|
}
|
|
|
|
.text-input-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 16px;
|
|
background: rgba(0, 0, 0, 0.1);
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
flex-shrink: 0;
|
|
transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out;
|
|
transform-origin: bottom;
|
|
}
|
|
|
|
.text-input-container.hidden {
|
|
opacity: 0;
|
|
transform: scaleY(0);
|
|
padding: 0;
|
|
height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.text-input-container.no-response {
|
|
border-top: none;
|
|
}
|
|
|
|
#textInput {
|
|
flex: 1;
|
|
padding: 10px 14px;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border-radius: 20px;
|
|
outline: none;
|
|
border: none;
|
|
color: white;
|
|
font-size: 14px;
|
|
font-family: 'Helvetica Neue', sans-serif;
|
|
font-weight: 400;
|
|
}
|
|
|
|
#textInput::placeholder {
|
|
color: rgba(255, 255, 255, 0.5);
|
|
}
|
|
|
|
#textInput:focus {
|
|
outline: none;
|
|
}
|
|
|
|
.response-line h1,
|
|
.response-line h2,
|
|
.response-line h3,
|
|
.response-line h4,
|
|
.response-line h5,
|
|
.response-line h6 {
|
|
color: rgba(255, 255, 255, 0.95);
|
|
margin: 16px 0 8px 0;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.response-line p {
|
|
margin: 8px 0;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
}
|
|
|
|
.response-line ul,
|
|
.response-line ol {
|
|
margin: 8px 0;
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.response-line li {
|
|
margin: 4px 0;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
}
|
|
|
|
.response-line code {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
color: rgba(255, 255, 255, 0.95);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-family: 'Monaco', 'Menlo', monospace;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.response-line pre {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: rgba(255, 255, 255, 0.95);
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
overflow-x: auto;
|
|
margin: 12px 0;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.response-line pre code {
|
|
background: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.response-line blockquote {
|
|
border-left: 3px solid rgba(255, 255, 255, 0.3);
|
|
margin: 12px 0;
|
|
padding: 8px 16px;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.btn-gap {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
gap: 4px;
|
|
}
|
|
|
|
/* ────────────────[ GLASS BYPASS ]─────────────── */
|
|
:host-context(body.has-glass) .ask-container,
|
|
:host-context(body.has-glass) .response-header,
|
|
:host-context(body.has-glass) .response-icon,
|
|
:host-context(body.has-glass) .copy-button,
|
|
:host-context(body.has-glass) .close-button,
|
|
:host-context(body.has-glass) .line-copy-button,
|
|
:host-context(body.has-glass) .text-input-container,
|
|
:host-context(body.has-glass) .response-container pre,
|
|
:host-context(body.has-glass) .response-container p code,
|
|
:host-context(body.has-glass) .response-container pre code {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
outline: none !important;
|
|
box-shadow: none !important;
|
|
filter: none !important;
|
|
backdrop-filter: none !important;
|
|
}
|
|
|
|
:host-context(body.has-glass) .ask-container::before {
|
|
display: none !important;
|
|
}
|
|
|
|
:host-context(body.has-glass) .copy-button:hover,
|
|
:host-context(body.has-glass) .close-button:hover,
|
|
:host-context(body.has-glass) .line-copy-button,
|
|
:host-context(body.has-glass) .line-copy-button:hover,
|
|
:host-context(body.has-glass) .response-line:hover {
|
|
background: transparent !important;
|
|
}
|
|
|
|
:host-context(body.has-glass) .response-container::-webkit-scrollbar-track,
|
|
:host-context(body.has-glass) .response-container::-webkit-scrollbar-thumb {
|
|
background: transparent !important;
|
|
}
|
|
|
|
.submit-btn, .clear-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
background: transparent;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
margin-left: 8px;
|
|
font-size: 13px;
|
|
font-family: 'Helvetica Neue', sans-serif;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
height: 32px;
|
|
padding: 0 10px;
|
|
box-shadow: none;
|
|
}
|
|
.submit-btn:hover, .clear-btn:hover {
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.btn-label {
|
|
margin-right: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
height: 100%;
|
|
}
|
|
.btn-icon {
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 13%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
.btn-icon img, .btn-icon svg {
|
|
width: 13px;
|
|
height: 13px;
|
|
display: block;
|
|
}
|
|
.header-clear-btn {
|
|
background: transparent;
|
|
border: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
cursor: pointer;
|
|
padding: 0 2px;
|
|
}
|
|
.header-clear-btn .icon-box {
|
|
color: white;
|
|
font-size: 12px;
|
|
font-family: 'Helvetica Neue', sans-serif;
|
|
font-weight: 500;
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
border-radius: 13%;
|
|
width: 18px;
|
|
height: 18px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.header-clear-btn:hover .icon-box {
|
|
background-color: rgba(255,255,255,0.18);
|
|
}
|
|
`;
|
|
|
|
constructor() {
|
|
super();
|
|
this.currentResponse = '';
|
|
this.currentQuestion = '';
|
|
this.isLoading = false;
|
|
this.copyState = 'idle';
|
|
this.showTextInput = true;
|
|
this.headerText = 'AI Response';
|
|
this.headerAnimating = false;
|
|
this.isStreaming = false;
|
|
|
|
this.marked = null;
|
|
this.hljs = null;
|
|
this.DOMPurify = null;
|
|
this.isLibrariesLoaded = false;
|
|
|
|
|
|
this.handleSendText = this.handleSendText.bind(this);
|
|
this.handleTextKeydown = this.handleTextKeydown.bind(this);
|
|
this.handleCopy = this.handleCopy.bind(this);
|
|
this.clearResponseContent = this.clearResponseContent.bind(this);
|
|
this.handleEscKey = this.handleEscKey.bind(this);
|
|
this.handleScroll = this.handleScroll.bind(this);
|
|
this.handleCloseAskWindow = this.handleCloseAskWindow.bind(this);
|
|
this.handleCloseIfNoContent = this.handleCloseIfNoContent.bind(this);
|
|
|
|
this.loadLibraries();
|
|
|
|
// --- Resize helpers ---
|
|
this.isThrottled = false;
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
console.log('📱 AskView connectedCallback - IPC 이벤트 리스너 설정');
|
|
|
|
document.addEventListener('keydown', this.handleEscKey);
|
|
|
|
this.resizeObserver = new ResizeObserver(entries => {
|
|
for (const entry of entries) {
|
|
const needed = entry.contentRect.height;
|
|
const current = window.innerHeight;
|
|
|
|
if (needed > current - 4) {
|
|
this.requestWindowResize(Math.ceil(needed));
|
|
}
|
|
}
|
|
});
|
|
|
|
const container = this.shadowRoot?.querySelector('.ask-container');
|
|
if (container) this.resizeObserver.observe(container);
|
|
|
|
this.handleQuestionFromAssistant = (event, question) => {
|
|
console.log('📨 AskView: Received question from ListenView:', question);
|
|
this.handleSendText(null, question);
|
|
};
|
|
|
|
if (window.api) {
|
|
window.api.askView.onShowTextInput(() => {
|
|
console.log('📤 Show text input signal received');
|
|
if (!this.showTextInput) {
|
|
this.showTextInput = true;
|
|
this.requestUpdate();
|
|
}
|
|
});
|
|
|
|
window.api.askView.onScrollResponseUp(() => this.handleScroll('up'));
|
|
window.api.askView.onScrollResponseDown(() => this.handleScroll('down'));
|
|
window.api.askView.onAskStateUpdate((event, newState) => {
|
|
this.currentResponse = newState.currentResponse;
|
|
this.currentQuestion = newState.currentQuestion;
|
|
this.isLoading = newState.isLoading;
|
|
this.isStreaming = newState.isStreaming;
|
|
this.showTextInput = newState.showTextInput;
|
|
});
|
|
console.log('✅ AskView: IPC 이벤트 리스너 등록 완료');
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.resizeObserver?.disconnect();
|
|
|
|
console.log('📱 AskView disconnectedCallback - IPC 이벤트 리스너 제거');
|
|
|
|
document.removeEventListener('keydown', this.handleEscKey);
|
|
|
|
if (this.copyTimeout) {
|
|
clearTimeout(this.copyTimeout);
|
|
}
|
|
|
|
if (this.headerAnimationTimeout) {
|
|
clearTimeout(this.headerAnimationTimeout);
|
|
}
|
|
|
|
if (this.streamingTimeout) {
|
|
clearTimeout(this.streamingTimeout);
|
|
}
|
|
|
|
Object.values(this.lineCopyTimeouts).forEach(timeout => clearTimeout(timeout));
|
|
|
|
if (window.api) {
|
|
window.api.askView.removeOnAskStateUpdate(this.handleAskStateUpdate);
|
|
window.api.askView.removeOnShowTextInput(this.handleShowTextInput);
|
|
window.api.askView.removeOnScrollResponseUp(this.handleScroll);
|
|
window.api.askView.removeOnScrollResponseDown(this.handleScroll);
|
|
console.log('✅ AskView: IPC 이벤트 리스너 제거 필요');
|
|
}
|
|
}
|
|
|
|
|
|
async loadLibraries() {
|
|
try {
|
|
if (!window.marked) {
|
|
await this.loadScript('../../assets/marked-4.3.0.min.js');
|
|
}
|
|
|
|
if (!window.hljs) {
|
|
await this.loadScript('../../assets/highlight-11.9.0.min.js');
|
|
}
|
|
|
|
if (!window.DOMPurify) {
|
|
await this.loadScript('../../assets/dompurify-3.0.7.min.js');
|
|
}
|
|
|
|
this.marked = window.marked;
|
|
this.hljs = window.hljs;
|
|
this.DOMPurify = window.DOMPurify;
|
|
|
|
if (this.marked && this.hljs) {
|
|
this.marked.setOptions({
|
|
highlight: (code, lang) => {
|
|
if (lang && this.hljs.getLanguage(lang)) {
|
|
try {
|
|
return this.hljs.highlight(code, { language: lang }).value;
|
|
} catch (err) {
|
|
console.warn('Highlight error:', err);
|
|
}
|
|
}
|
|
try {
|
|
return this.hljs.highlightAuto(code).value;
|
|
} catch (err) {
|
|
console.warn('Auto highlight error:', err);
|
|
}
|
|
return code;
|
|
},
|
|
breaks: true,
|
|
gfm: true,
|
|
pedantic: false,
|
|
smartypants: false,
|
|
xhtml: false,
|
|
});
|
|
|
|
this.isLibrariesLoaded = true;
|
|
this.renderContent();
|
|
console.log('Markdown libraries loaded successfully in AskView');
|
|
}
|
|
|
|
if (this.DOMPurify) {
|
|
this.isDOMPurifyLoaded = true;
|
|
console.log('DOMPurify loaded successfully in AskView');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load libraries in AskView:', error);
|
|
}
|
|
}
|
|
|
|
handleCloseAskWindow() {
|
|
// this.clearResponseContent();
|
|
window.api.askView.closeAskWindow();
|
|
}
|
|
|
|
handleCloseIfNoContent() {
|
|
if (!this.currentResponse && !this.isLoading && !this.isStreaming) {
|
|
this.handleCloseAskWindow();
|
|
}
|
|
}
|
|
|
|
handleEscKey(e) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
this.handleCloseIfNoContent();
|
|
}
|
|
}
|
|
|
|
clearResponseContent() {
|
|
this.currentResponse = '';
|
|
this.currentQuestion = '';
|
|
this.isLoading = false;
|
|
this.isStreaming = false;
|
|
this.headerText = 'AI Response';
|
|
this.showTextInput = true;
|
|
}
|
|
|
|
handleInputFocus() {
|
|
this.isInputFocused = true;
|
|
}
|
|
|
|
focusTextInput() {
|
|
requestAnimationFrame(() => {
|
|
const textInput = this.shadowRoot?.getElementById('textInput');
|
|
if (textInput) {
|
|
textInput.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
loadScript(src) {
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
parseMarkdown(text) {
|
|
if (!text) return '';
|
|
|
|
if (!this.isLibrariesLoaded || !this.marked) {
|
|
return text;
|
|
}
|
|
|
|
try {
|
|
return this.marked(text);
|
|
} catch (error) {
|
|
console.error('Markdown parsing error in AskView:', error);
|
|
return text;
|
|
}
|
|
}
|
|
|
|
fixIncompleteCodeBlocks(text) {
|
|
if (!text) return text;
|
|
|
|
const codeBlockMarkers = text.match(/```/g) || [];
|
|
const markerCount = codeBlockMarkers.length;
|
|
|
|
if (markerCount % 2 === 1) {
|
|
return text + '\n```';
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
handleScroll(direction) {
|
|
const scrollableElement = this.shadowRoot.querySelector('#responseContainer');
|
|
if (scrollableElement) {
|
|
const scrollAmount = 100; // 한 번에 스크롤할 양 (px)
|
|
if (direction === 'up') {
|
|
scrollableElement.scrollTop -= scrollAmount;
|
|
} else {
|
|
scrollableElement.scrollTop += scrollAmount;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
renderContent() {
|
|
const responseContainer = this.shadowRoot.getElementById('responseContainer');
|
|
if (!responseContainer) return;
|
|
|
|
// ✨ 로딩 상태를 먼저 확인
|
|
if (this.isLoading) {
|
|
responseContainer.innerHTML = `<div class="loading-dots">...</div>`;
|
|
return;
|
|
}
|
|
|
|
// ✨ 응답이 없을 때의 처리
|
|
if (!this.currentResponse) {
|
|
responseContainer.innerHTML = `<div class="empty-state">...</div>`;
|
|
return;
|
|
}
|
|
|
|
let textToRender = this.fixIncompleteMarkdown(this.currentResponse);
|
|
textToRender = this.fixIncompleteCodeBlocks(textToRender);
|
|
|
|
|
|
if (this.isLibrariesLoaded && this.marked && this.DOMPurify) {
|
|
try {
|
|
// 마크다운 파싱
|
|
const parsedHtml = this.marked.parse(textToRender);
|
|
|
|
// DOMPurify로 정제
|
|
const cleanHtml = this.DOMPurify.sanitize(parsedHtml, {
|
|
ALLOWED_TAGS: [
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'p',
|
|
'br',
|
|
'strong',
|
|
'b',
|
|
'em',
|
|
'i',
|
|
'ul',
|
|
'ol',
|
|
'li',
|
|
'blockquote',
|
|
'code',
|
|
'pre',
|
|
'a',
|
|
'img',
|
|
'table',
|
|
'thead',
|
|
'tbody',
|
|
'tr',
|
|
'th',
|
|
'td',
|
|
'hr',
|
|
'sup',
|
|
'sub',
|
|
'del',
|
|
'ins',
|
|
],
|
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'id', 'target', 'rel'],
|
|
});
|
|
|
|
// HTML 적용
|
|
responseContainer.innerHTML = cleanHtml;
|
|
|
|
// 코드 하이라이팅 적용
|
|
if (this.hljs) {
|
|
responseContainer.querySelectorAll('pre code').forEach(block => {
|
|
this.hljs.highlightElement(block);
|
|
});
|
|
}
|
|
|
|
// 스크롤을 맨 아래로
|
|
responseContainer.scrollTop = responseContainer.scrollHeight;
|
|
} catch (error) {
|
|
console.error('Error rendering markdown:', error);
|
|
// 에러 발생 시 일반 텍스트로 표시
|
|
responseContainer.textContent = textToRender;
|
|
}
|
|
} else {
|
|
// 라이브러리가 로드되지 않았을 때 기본 렌더링
|
|
const basicHtml = textToRender
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n\n/g, '</p><p>')
|
|
.replace(/\n/g, '<br>')
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
|
|
responseContainer.innerHTML = `<p>${basicHtml}</p>`;
|
|
}
|
|
|
|
// 🚀 After updating content, recalculate window height
|
|
this.adjustWindowHeightThrottled();
|
|
}
|
|
|
|
|
|
requestWindowResize(targetHeight) {
|
|
if (window.api) {
|
|
window.api.askView.adjustWindowHeight(targetHeight);
|
|
}
|
|
}
|
|
|
|
animateHeaderText(text) {
|
|
this.headerAnimating = true;
|
|
this.requestUpdate();
|
|
|
|
setTimeout(() => {
|
|
this.headerText = text;
|
|
this.headerAnimating = false;
|
|
this.requestUpdate();
|
|
}, 150);
|
|
}
|
|
|
|
startHeaderAnimation() {
|
|
this.animateHeaderText('analyzing screen...');
|
|
|
|
if (this.headerAnimationTimeout) {
|
|
clearTimeout(this.headerAnimationTimeout);
|
|
}
|
|
|
|
this.headerAnimationTimeout = setTimeout(() => {
|
|
this.animateHeaderText('thinking...');
|
|
}, 1500);
|
|
}
|
|
|
|
renderMarkdown(content) {
|
|
if (!content) return '';
|
|
|
|
if (this.isLibrariesLoaded && this.marked) {
|
|
return this.parseMarkdown(content);
|
|
}
|
|
|
|
return content
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
.replace(/`(.*?)`/g, '<code>$1</code>');
|
|
}
|
|
|
|
fixIncompleteMarkdown(text) {
|
|
if (!text) return text;
|
|
|
|
// 불완전한 볼드체 처리
|
|
const boldCount = (text.match(/\*\*/g) || []).length;
|
|
if (boldCount % 2 === 1) {
|
|
text += '**';
|
|
}
|
|
|
|
// 불완전한 이탤릭체 처리
|
|
const italicCount = (text.match(/(?<!\*)\*(?!\*)/g) || []).length;
|
|
if (italicCount % 2 === 1) {
|
|
text += '*';
|
|
}
|
|
|
|
// 불완전한 인라인 코드 처리
|
|
const inlineCodeCount = (text.match(/`/g) || []).length;
|
|
if (inlineCodeCount % 2 === 1) {
|
|
text += '`';
|
|
}
|
|
|
|
// 불완전한 링크 처리
|
|
const openBrackets = (text.match(/\[/g) || []).length;
|
|
const closeBrackets = (text.match(/\]/g) || []).length;
|
|
if (openBrackets > closeBrackets) {
|
|
text += ']';
|
|
}
|
|
|
|
const openParens = (text.match(/\]\(/g) || []).length;
|
|
const closeParens = (text.match(/\)\s*$/g) || []).length;
|
|
if (openParens > closeParens && text.endsWith('(')) {
|
|
text += ')';
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
|
|
async handleCopy() {
|
|
if (this.copyState === 'copied') return;
|
|
|
|
let responseToCopy = this.currentResponse;
|
|
|
|
if (this.isDOMPurifyLoaded && this.DOMPurify) {
|
|
const testHtml = this.renderMarkdown(responseToCopy);
|
|
const sanitized = this.DOMPurify.sanitize(testHtml);
|
|
|
|
if (this.DOMPurify.removed && this.DOMPurify.removed.length > 0) {
|
|
console.warn('Unsafe content detected, copy blocked');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const textToCopy = `Question: ${this.currentQuestion}\n\nAnswer: ${responseToCopy}`;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(textToCopy);
|
|
console.log('Content copied to clipboard');
|
|
|
|
this.copyState = 'copied';
|
|
this.requestUpdate();
|
|
|
|
if (this.copyTimeout) {
|
|
clearTimeout(this.copyTimeout);
|
|
}
|
|
|
|
this.copyTimeout = setTimeout(() => {
|
|
this.copyState = 'idle';
|
|
this.requestUpdate();
|
|
}, 1500);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
}
|
|
|
|
async handleLineCopy(lineIndex) {
|
|
const originalLines = this.currentResponse.split('\n');
|
|
const lineToCopy = originalLines[lineIndex];
|
|
|
|
if (!lineToCopy) return;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(lineToCopy);
|
|
console.log('Line copied to clipboard');
|
|
|
|
// '복사됨' 상태로 UI 즉시 업데이트
|
|
this.lineCopyState = { ...this.lineCopyState, [lineIndex]: true };
|
|
this.requestUpdate(); // LitElement에 UI 업데이트 요청
|
|
|
|
// 기존 타임아웃이 있다면 초기화
|
|
if (this.lineCopyTimeouts && this.lineCopyTimeouts[lineIndex]) {
|
|
clearTimeout(this.lineCopyTimeouts[lineIndex]);
|
|
}
|
|
|
|
// ✨ 수정된 타임아웃: 1.5초 후 '복사됨' 상태 해제
|
|
this.lineCopyTimeouts[lineIndex] = setTimeout(() => {
|
|
const updatedState = { ...this.lineCopyState };
|
|
delete updatedState[lineIndex];
|
|
this.lineCopyState = updatedState;
|
|
this.requestUpdate(); // UI 업데이트 요청
|
|
}, 1500);
|
|
} catch (err) {
|
|
console.error('Failed to copy line:', err);
|
|
}
|
|
}
|
|
|
|
async handleSendText(e, overridingText = '') {
|
|
const textInput = this.shadowRoot?.getElementById('textInput');
|
|
const text = (overridingText || textInput?.value || '').trim();
|
|
if (!text) return;
|
|
|
|
textInput.value = '';
|
|
|
|
if (window.api) {
|
|
window.api.askView.sendMessage(text).catch(error => {
|
|
console.error('Error sending text:', error);
|
|
});
|
|
}
|
|
}
|
|
|
|
handleTextKeydown(e) {
|
|
// Fix for IME composition issue: Ignore Enter key presses while composing.
|
|
if (e.isComposing) {
|
|
return;
|
|
}
|
|
|
|
const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey;
|
|
const isModifierEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
|
|
|
|
if (isPlainEnter || isModifierEnter) {
|
|
e.preventDefault();
|
|
this.handleSendText();
|
|
}
|
|
}
|
|
|
|
updated(changedProperties) {
|
|
super.updated(changedProperties);
|
|
|
|
// ✨ isLoading 또는 currentResponse가 변경될 때마다 뷰를 다시 그립니다.
|
|
if (changedProperties.has('isLoading') || changedProperties.has('currentResponse')) {
|
|
this.renderContent();
|
|
}
|
|
|
|
if (changedProperties.has('showTextInput') || changedProperties.has('isLoading') || changedProperties.has('currentResponse')) {
|
|
this.adjustWindowHeightThrottled();
|
|
}
|
|
|
|
if (changedProperties.has('showTextInput') && this.showTextInput) {
|
|
this.focusTextInput();
|
|
}
|
|
}
|
|
|
|
firstUpdated() {
|
|
setTimeout(() => this.adjustWindowHeight(), 200);
|
|
}
|
|
|
|
|
|
getTruncatedQuestion(question, maxLength = 30) {
|
|
if (!question) return '';
|
|
if (question.length <= maxLength) return question;
|
|
return question.substring(0, maxLength) + '...';
|
|
}
|
|
|
|
|
|
|
|
render() {
|
|
const hasResponse = this.isLoading || this.currentResponse || this.isStreaming;
|
|
const headerText = this.isLoading ? 'Thinking...' : 'AI Response';
|
|
|
|
return html`
|
|
<div class="ask-container">
|
|
<!-- Response Header -->
|
|
<div class="response-header ${!hasResponse ? 'hidden' : ''}">
|
|
<div class="header-left">
|
|
<div class="response-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
|
|
<path d="M8 12l2 2 4-4" />
|
|
</svg>
|
|
</div>
|
|
<span class="response-label">${headerText}</span>
|
|
</div>
|
|
<div class="header-right">
|
|
<span class="question-text">${this.getTruncatedQuestion(this.currentQuestion)}</span>
|
|
<div class="header-controls">
|
|
<button class="copy-button ${this.copyState === 'copied' ? 'copied' : ''}" @click=${this.handleCopy}>
|
|
<svg class="copy-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
|
</svg>
|
|
<svg
|
|
class="check-icon"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
>
|
|
<path d="M20 6L9 17l-5-5" />
|
|
</svg>
|
|
</button>
|
|
<button class="close-button" @click=${this.handleCloseAskWindow}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Response Container -->
|
|
<div class="response-container ${!hasResponse ? 'hidden' : ''}" id="responseContainer">
|
|
<!-- Content is dynamically generated in updateResponseContent() -->
|
|
</div>
|
|
|
|
<!-- Text Input Container -->
|
|
<div class="text-input-container ${!hasResponse ? 'no-response' : ''} ${!this.showTextInput ? 'hidden' : ''}">
|
|
<input
|
|
type="text"
|
|
id="textInput"
|
|
placeholder="Ask about your screen or audio"
|
|
@keydown=${this.handleTextKeydown}
|
|
@focus=${this.handleInputFocus}
|
|
/>
|
|
<button
|
|
class="submit-btn"
|
|
@click=${this.handleSendText}
|
|
>
|
|
<span class="btn-label">Submit</span>
|
|
<span class="btn-icon">
|
|
↵
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Dynamically resize the BrowserWindow to fit current content
|
|
adjustWindowHeight() {
|
|
if (!window.api) return;
|
|
|
|
this.updateComplete.then(() => {
|
|
const headerEl = this.shadowRoot.querySelector('.response-header');
|
|
const responseEl = this.shadowRoot.querySelector('.response-container');
|
|
const inputEl = this.shadowRoot.querySelector('.text-input-container');
|
|
|
|
if (!headerEl || !responseEl) return;
|
|
|
|
const headerHeight = headerEl.classList.contains('hidden') ? 0 : headerEl.offsetHeight;
|
|
const responseHeight = responseEl.scrollHeight;
|
|
const inputHeight = (inputEl && !inputEl.classList.contains('hidden')) ? inputEl.offsetHeight : 0;
|
|
|
|
const idealHeight = headerHeight + responseHeight + inputHeight;
|
|
|
|
const targetHeight = Math.min(700, idealHeight);
|
|
|
|
window.api.askView.adjustWindowHeight(targetHeight);
|
|
|
|
}).catch(err => console.error('AskView adjustWindowHeight error:', err));
|
|
}
|
|
|
|
// Throttled wrapper to avoid excessive IPC spam (executes at most once per animation frame)
|
|
adjustWindowHeightThrottled() {
|
|
if (this.isThrottled) return;
|
|
|
|
this.isThrottled = true;
|
|
requestAnimationFrame(() => {
|
|
this.adjustWindowHeight();
|
|
this.isThrottled = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
customElements.define('ask-view', AskView);
|