feat: improve Heroprompt UI and refactor modules
- Refactor all UI rendering logic into a single `ui` module - Centralize static assets serving to `/static` directory - Redesign Heroprompt page with Bootstrap 5 components - Enhance workspace management and file tree interactions - Add Bootstrap modal support for UI dialogs
This commit is contained in:
489
lib/web/ui/static/css/chat.css
Normal file
489
lib/web/ui/static/css/chat.css
Normal file
@@ -0,0 +1,489 @@
|
||||
/* Chat Widget Styles */
|
||||
|
||||
/* Chat Container Layout */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 44px - 32px);
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-primary);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.chat-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Chat Messages Area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
max-width: 80%;
|
||||
animation: messageSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.user .message-avatar {
|
||||
background-color: var(--link-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message.assistant .message-avatar {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
background-color: var(--card-bg);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.user .message-text {
|
||||
background-color: var(--link-color);
|
||||
color: white;
|
||||
border-color: var(--link-color);
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message.user .message-time {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Chat Input Area */
|
||||
.chat-input-container {
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 20px;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
border-color: var(--link-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
/* Recorder Container */
|
||||
.recorder-container {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recorder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.recorder-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Recording Status */
|
||||
.recording-status {
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.recording-status.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.recording-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.recording-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--danger-color);
|
||||
border-radius: 50%;
|
||||
animation: recordingPulse 1s infinite;
|
||||
}
|
||||
|
||||
.recording-time {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.recording-level {
|
||||
height: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.level-bar {
|
||||
height: 100%;
|
||||
background-color: var(--success-color);
|
||||
width: 0%;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
/* Recordings Explorer */
|
||||
.recordings-explorer {
|
||||
padding: 1rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.explorer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.explorer-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.explorer-tree {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tree-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tree-item-content:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tree-item-content.selected {
|
||||
background-color: var(--link-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tree-item-name {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tree-item-size {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tree-item-children {
|
||||
margin-left: 1rem;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.tree-item.folder > .tree-item-content > i {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.tree-item.file > .tree-item-content > i {
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px var(--card-shadow);
|
||||
padding: 0.25rem 0;
|
||||
min-width: 150px;
|
||||
z-index: 1060;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-primary);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes messageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recordingPulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Voice Input States */
|
||||
.voice-input-active {
|
||||
background-color: var(--danger-color) !important;
|
||||
border-color: var(--danger-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.voice-input-processing {
|
||||
background-color: var(--warning-color) !important;
|
||||
border-color: var(--warning-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Upload Progress */
|
||||
.upload-progress {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: var(--link-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
height: calc(100vh - 44px - 16px);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-input-actions {
|
||||
order: -1;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recordings-explorer {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme specific adjustments */
|
||||
[data-theme="dark"] .chat-input {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .message.user .message-text {
|
||||
background-color: var(--link-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .context-menu {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Bootstrap Icons Integration */
|
||||
.bi {
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Typing Indicator */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* File attachment preview */
|
||||
.file-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file-attachment-icon {
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
.file-attachment-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-attachment-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-attachment-size {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-attachment-remove {
|
||||
color: var(--danger-color);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
329
lib/web/ui/static/css/heroprompt.css
Normal file
329
lib/web/ui/static/css/heroprompt.css
Normal file
@@ -0,0 +1,329 @@
|
||||
/* Heroprompt specific styles using UI project theme system */
|
||||
|
||||
/* Tree view specific styles */
|
||||
.tree {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tree .chev {
|
||||
transition: transform 0.15s ease;
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
opacity: var(--menu-chevron-opacity);
|
||||
}
|
||||
|
||||
.tree .toggle:checked+.dir-label .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree .children {
|
||||
margin-left: 16px;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.tree .dir-label {
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tree .dir-label:hover {
|
||||
background-color: var(--menu-item-hover-bg);
|
||||
color: var(--menu-item-hover-text);
|
||||
}
|
||||
|
||||
.tree .file {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tree .file:hover {
|
||||
background-color: var(--menu-item-hover-bg);
|
||||
}
|
||||
|
||||
.tree .file a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.tree .file a:hover {
|
||||
color: var(--link-hover-color);
|
||||
}
|
||||
|
||||
/* Tab content styling */
|
||||
.tab-pane {
|
||||
height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Selection list styling */
|
||||
#selected {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
#selected li {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#selected li:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Preview area styling */
|
||||
#preview {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Prompt output styling */
|
||||
#promptOutput {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Chat messages styling */
|
||||
#chatMessages {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Form controls theme integration */
|
||||
.form-control {
|
||||
background-color: var(--bg-primary);
|
||||
border-color: var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: var(--bg-primary);
|
||||
border-color: var(--link-color);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Select elements */
|
||||
.form-select {
|
||||
background-color: var(--bg-primary);
|
||||
border-color: var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
background-color: var(--bg-primary);
|
||||
border-color: var(--link-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Button theme integration */
|
||||
.btn-outline-secondary {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--link-color);
|
||||
border-color: var(--link-color);
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--link-color);
|
||||
border-color: var(--link-color);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
color: var(--danger-color);
|
||||
border-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.btn-outline-danger:hover {
|
||||
background-color: var(--danger-color);
|
||||
border-color: var(--danger-color);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
.badge {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Modal theme integration */
|
||||
.modal-content {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top-color: var(--border-primary);
|
||||
}
|
||||
|
||||
/* Alert styling */
|
||||
.alert-success {
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
border-color: var(--success-color);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
border-color: var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Tree node buttons */
|
||||
.tree .btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Workspace info styling */
|
||||
.workspace-info {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: -10px 0 0 -10px;
|
||||
border: 2px solid var(--border-primary);
|
||||
border-top-color: var(--link-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error states */
|
||||
.error-message {
|
||||
color: var(--danger-color);
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Success states */
|
||||
.success-message {
|
||||
color: var(--success-color);
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
border: 1px solid var(--success-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.tree {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
#preview,
|
||||
#promptOutput {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
|
||||
.tree .dir-label:hover,
|
||||
.tree .file:hover {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary,
|
||||
.btn-outline-secondary,
|
||||
.btn-outline-danger {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.tree .chev,
|
||||
.tree .dir-label,
|
||||
.tree .file,
|
||||
.form-control,
|
||||
.btn {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
529
lib/web/ui/static/css/heroscript.css
Normal file
529
lib/web/ui/static/css/heroscript.css
Normal file
@@ -0,0 +1,529 @@
|
||||
/* HeroScript Editor Specific Styles */
|
||||
|
||||
/* Full height layout for editor */
|
||||
.main .container-fluid {
|
||||
height: calc(100vh - 44px - 2rem); /* Account for header and padding */
|
||||
}
|
||||
|
||||
/* Resizable container layout */
|
||||
.resizable-container {
|
||||
display: flex;
|
||||
height: calc(100% - 60px); /* Account for header section */
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
max-width: calc(100% - 250px); /* Ensure logs panel has minimum space */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-panel {
|
||||
width: 350px;
|
||||
min-width: 200px;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Resize handle styles */
|
||||
.resize-handle {
|
||||
background-color: var(--border-primary);
|
||||
position: relative;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle.horizontal {
|
||||
width: 4px;
|
||||
min-width: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background-color: var(--link-color);
|
||||
}
|
||||
|
||||
.resize-handle.dragging {
|
||||
background-color: var(--link-color);
|
||||
}
|
||||
|
||||
.resize-handle-line {
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background-color: var(--border-secondary);
|
||||
border-radius: 1px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle:hover .resize-handle-line,
|
||||
.resize-handle.dragging .resize-handle-line {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Prevent text selection during resize */
|
||||
.resizing {
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resizing * {
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Editor Panel Styles */
|
||||
#editor-container {
|
||||
position: relative;
|
||||
background-color: var(--bg-primary);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#script-editor {
|
||||
border: none;
|
||||
resize: none;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0;
|
||||
background-color: var(--bg-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
white-space: pre;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-height: 500px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Highlighted overlay for syntax highlighting */
|
||||
.highlighted-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
padding: 1.5rem;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#script-editor:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--link-color);
|
||||
background-color: var(--bg-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
#script-editor::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Simple syntax highlighting for basic keywords */
|
||||
.hljs {
|
||||
background-color: var(--bg-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
padding: 1.5rem;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Dark theme syntax highlighting */
|
||||
[data-theme="dark"] .hljs {
|
||||
background-color: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-keyword {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-string {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-comment {
|
||||
color: #6a9955;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-number {
|
||||
color: #b5cea8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-function {
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
/* Light theme syntax highlighting */
|
||||
[data-theme="light"] .hljs-keyword {
|
||||
color: #0000ff;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hljs-string {
|
||||
color: #a31515;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hljs-comment {
|
||||
color: #008000;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hljs-number {
|
||||
color: #098658;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hljs-function {
|
||||
color: #795e26;
|
||||
}
|
||||
|
||||
/* Logs Panel Styles */
|
||||
#logs-container {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#logs-content {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Log Entry Styles */
|
||||
.log-entry {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Compact log format: cat: message */
|
||||
.log-entry.compact {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.log-entry.compact .log-category {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-entry.compact .log-message {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Legacy log entry styles (for backward compatibility) */
|
||||
.log-entry .timestamp {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.log-entry .level {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-right: 0.5rem;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.log-entry .level.info {
|
||||
background-color: var(--info-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-entry .level.success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-entry .level.warning {
|
||||
background-color: var(--warning-color);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.log-entry .level.error {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-entry .level.debug {
|
||||
background-color: var(--text-muted);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-entry .message {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.log-entry.system {
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
border-left: 3px solid var(--danger-color);
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.log-entry.warning {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
border-left: 3px solid var(--warning-color);
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* Card Header Styles */
|
||||
.card-header {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.card-header h6 {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Control Buttons */
|
||||
.editor-controls select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.log-controls .badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
#connection-status.bg-success {
|
||||
background-color: var(--success-color) !important;
|
||||
}
|
||||
|
||||
#connection-status.bg-danger {
|
||||
background-color: var(--danger-color) !important;
|
||||
}
|
||||
|
||||
#connection-status.bg-warning {
|
||||
background-color: var(--warning-color) !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Auto-scroll button states */
|
||||
#auto-scroll[data-active="true"] {
|
||||
background-color: var(--link-color);
|
||||
border-color: var(--link-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
#auto-scroll[data-active="false"] {
|
||||
background-color: transparent;
|
||||
border-color: var(--border-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.btn-primary {
|
||||
background-color: var(--link-color);
|
||||
border-color: var(--link-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--link-hover-color);
|
||||
border-color: var(--link-hover-color);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
border-color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
border-color: var(--border-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.resizable-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
height: 65vh;
|
||||
margin-bottom: 1rem;
|
||||
max-width: none;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.logs-panel {
|
||||
height: 35vh;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.resize-handle.horizontal {
|
||||
display: none; /* Hide horizontal resize handle on mobile */
|
||||
}
|
||||
|
||||
#script-editor {
|
||||
font-size: 14px;
|
||||
padding: 1rem;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.d-flex.align-items-center.mb-3 {
|
||||
flex-direction: column;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.ms-auto {
|
||||
margin-top: 1rem !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Medium screens - vertical layout with resizable height */
|
||||
@media (min-width: 769px) and (max-width: 1200px) {
|
||||
.resizable-container {
|
||||
flex-direction: column;
|
||||
height: calc(100% - 60px);
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
max-height: calc(100% - 200px);
|
||||
max-width: none;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.logs-panel {
|
||||
height: 250px;
|
||||
min-height: 150px;
|
||||
max-height: 400px;
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.resize-handle.horizontal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resize-handle.vertical {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
min-height: 4px;
|
||||
cursor: row-resize;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.resize-handle.vertical .resize-handle-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
#logs-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#logs-container::-webkit-scrollbar-track {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
#logs-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#logs-container::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
/* Animation for new log entries */
|
||||
@keyframes slideInLog {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.log-entry.new {
|
||||
animation: slideInLog 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Focus indicators for accessibility */
|
||||
.btn:focus,
|
||||
.form-select:focus,
|
||||
#script-editor:focus {
|
||||
outline: 2px solid var(--link-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
583
lib/web/ui/static/js/chat.js
Normal file
583
lib/web/ui/static/js/chat.js
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Chat Widget JavaScript
|
||||
* Handles chat functionality, voice recording, and file management
|
||||
*/
|
||||
|
||||
class ChatWidget {
|
||||
constructor() {
|
||||
this.messages = [];
|
||||
this.isRecording = false;
|
||||
this.mediaRecorder = null;
|
||||
this.audioChunks = [];
|
||||
this.recordingStartTime = null;
|
||||
this.recordingTimer = null;
|
||||
this.selectedFiles = [];
|
||||
this.recordings = [];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.initializeRecorder();
|
||||
this.loadRecordings();
|
||||
this.setupContextMenu();
|
||||
this.autoResizeTextarea();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Chat input events
|
||||
const chatInput = document.getElementById('chatInput');
|
||||
const sendButton = document.getElementById('sendMessage');
|
||||
|
||||
chatInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
chatInput.addEventListener('input', () => {
|
||||
this.autoResizeTextarea();
|
||||
});
|
||||
|
||||
sendButton.addEventListener('click', () => {
|
||||
this.sendMessage();
|
||||
});
|
||||
|
||||
// Voice input button
|
||||
document.getElementById('voiceInput').addEventListener('click', () => {
|
||||
this.toggleVoiceInput();
|
||||
});
|
||||
|
||||
// File attachment
|
||||
document.getElementById('attachFile').addEventListener('click', () => {
|
||||
this.showFileUploadModal();
|
||||
});
|
||||
|
||||
// Clear chat
|
||||
document.getElementById('clearChat').addEventListener('click', () => {
|
||||
this.clearChat();
|
||||
});
|
||||
|
||||
// Recording controls
|
||||
document.getElementById('recordBtn').addEventListener('click', () => {
|
||||
this.startRecording();
|
||||
});
|
||||
|
||||
document.getElementById('stopBtn').addEventListener('click', () => {
|
||||
this.stopRecording();
|
||||
});
|
||||
|
||||
document.getElementById('playBtn').addEventListener('click', () => {
|
||||
this.playLastRecording();
|
||||
});
|
||||
|
||||
// Explorer actions
|
||||
document.getElementById('newFolderBtn').addEventListener('click', () => {
|
||||
this.createNewFolder();
|
||||
});
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => {
|
||||
this.refreshRecordings();
|
||||
});
|
||||
|
||||
// File upload modal
|
||||
document.getElementById('uploadBtn').addEventListener('click', () => {
|
||||
this.uploadFile();
|
||||
});
|
||||
|
||||
// Tree item clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.tree-item-content')) {
|
||||
this.handleTreeItemClick(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Context menu
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
if (e.target.closest('.tree-item-content')) {
|
||||
e.preventDefault();
|
||||
this.showContextMenu(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Hide context menu on click outside
|
||||
document.addEventListener('click', () => {
|
||||
this.hideContextMenu();
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage() {
|
||||
const input = document.getElementById('chatInput');
|
||||
const message = input.value.trim();
|
||||
|
||||
if (!message && this.selectedFiles.length === 0) return;
|
||||
|
||||
// Add user message
|
||||
this.addMessage('user', message, this.selectedFiles);
|
||||
|
||||
// Clear input and files
|
||||
input.value = '';
|
||||
this.selectedFiles = [];
|
||||
this.autoResizeTextarea();
|
||||
|
||||
// Show typing indicator
|
||||
this.showTypingIndicator();
|
||||
|
||||
// Simulate AI response (replace with actual API call)
|
||||
setTimeout(() => {
|
||||
this.hideTypingIndicator();
|
||||
this.addMessage('assistant', this.generateAIResponse(message));
|
||||
}, 1000 + Math.random() * 2000);
|
||||
}
|
||||
|
||||
addMessage(sender, text, files = []) {
|
||||
const messagesContainer = document.getElementById('chatMessages');
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `message ${sender}`;
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'message-avatar';
|
||||
avatar.innerHTML = sender === 'user' ? '<i class="bi bi-person"></i>' : '<i class="bi bi-robot"></i>';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'message-content';
|
||||
|
||||
// Add file attachments if any
|
||||
if (files.length > 0) {
|
||||
files.forEach(file => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'file-attachment';
|
||||
fileDiv.innerHTML = `
|
||||
<i class="bi bi-file-earmark file-attachment-icon"></i>
|
||||
<div class="file-attachment-info">
|
||||
<div class="file-attachment-name">${file.name}</div>
|
||||
<div class="file-attachment-size">${this.formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
`;
|
||||
content.appendChild(fileDiv);
|
||||
});
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.className = 'message-text';
|
||||
textDiv.textContent = text;
|
||||
content.appendChild(textDiv);
|
||||
}
|
||||
|
||||
const timeDiv = document.createElement('div');
|
||||
timeDiv.className = 'message-time';
|
||||
timeDiv.textContent = new Date().toLocaleTimeString();
|
||||
content.appendChild(timeDiv);
|
||||
|
||||
messageDiv.appendChild(avatar);
|
||||
messageDiv.appendChild(content);
|
||||
|
||||
messagesContainer.appendChild(messageDiv);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
|
||||
this.messages.push({ sender, text, files, timestamp: new Date() });
|
||||
}
|
||||
|
||||
showTypingIndicator() {
|
||||
const messagesContainer = document.getElementById('chatMessages');
|
||||
const typingDiv = document.createElement('div');
|
||||
typingDiv.className = 'message assistant typing-indicator';
|
||||
typingDiv.id = 'typingIndicator';
|
||||
typingDiv.innerHTML = `
|
||||
<div class="message-avatar">
|
||||
<i class="bi bi-robot"></i>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="typing-indicator">
|
||||
AI is typing
|
||||
<div class="typing-dots">
|
||||
<div class="typing-dot"></div>
|
||||
<div class="typing-dot"></div>
|
||||
<div class="typing-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
messagesContainer.appendChild(typingDiv);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
hideTypingIndicator() {
|
||||
const typingIndicator = document.getElementById('typingIndicator');
|
||||
if (typingIndicator) {
|
||||
typingIndicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
generateAIResponse(userMessage) {
|
||||
const responses = [
|
||||
"I understand your question. Let me help you with that.",
|
||||
"That's an interesting point. Here's what I think...",
|
||||
"Based on what you've shared, I'd suggest...",
|
||||
"I can help you with that. Here are some options...",
|
||||
"Thank you for the information. Let me process that...",
|
||||
"I see what you're asking. Here's my response..."
|
||||
];
|
||||
return responses[Math.floor(Math.random() * responses.length)];
|
||||
}
|
||||
|
||||
clearChat() {
|
||||
if (confirm('Are you sure you want to clear all messages?')) {
|
||||
document.getElementById('chatMessages').innerHTML = `
|
||||
<div class="message assistant">
|
||||
<div class="message-avatar">
|
||||
<i class="bi bi-robot"></i>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-text">Hello! I'm your AI assistant. How can I help you today?</div>
|
||||
<div class="message-time">Just now</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.messages = [];
|
||||
}
|
||||
}
|
||||
|
||||
autoResizeTextarea() {
|
||||
const textarea = document.getElementById('chatInput');
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
// Voice Recording Functions
|
||||
async initializeRecorder() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.mediaRecorder = new MediaRecorder(stream);
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
this.audioChunks.push(event.data);
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = () => {
|
||||
this.saveRecording();
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error);
|
||||
this.showStatus('Microphone access denied', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
startRecording() {
|
||||
if (!this.mediaRecorder) {
|
||||
this.showStatus('Microphone not available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.audioChunks = [];
|
||||
this.mediaRecorder.start();
|
||||
this.isRecording = true;
|
||||
this.recordingStartTime = Date.now();
|
||||
|
||||
// Update UI
|
||||
document.getElementById('recordBtn').disabled = true;
|
||||
document.getElementById('stopBtn').disabled = false;
|
||||
document.getElementById('recordingStatus').classList.add('active');
|
||||
|
||||
// Start timer
|
||||
this.recordingTimer = setInterval(() => {
|
||||
this.updateRecordingTime();
|
||||
}, 1000);
|
||||
|
||||
this.showStatus('Recording started...', 'success');
|
||||
}
|
||||
|
||||
stopRecording() {
|
||||
if (!this.isRecording) return;
|
||||
|
||||
this.mediaRecorder.stop();
|
||||
this.isRecording = false;
|
||||
|
||||
// Update UI
|
||||
document.getElementById('recordBtn').disabled = false;
|
||||
document.getElementById('stopBtn').disabled = true;
|
||||
document.getElementById('playBtn').disabled = false;
|
||||
document.getElementById('recordingStatus').classList.remove('active');
|
||||
|
||||
// Stop timer
|
||||
clearInterval(this.recordingTimer);
|
||||
|
||||
this.showStatus('Recording stopped', 'success');
|
||||
}
|
||||
|
||||
updateRecordingTime() {
|
||||
const elapsed = Math.floor((Date.now() - this.recordingStartTime) / 1000);
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
const seconds = elapsed % 60;
|
||||
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
document.querySelector('.recording-time').textContent = timeString;
|
||||
}
|
||||
|
||||
saveRecording() {
|
||||
const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `recording-${timestamp}.wav`;
|
||||
|
||||
// Create download link (in real implementation, upload to server)
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
const recording = {
|
||||
name: filename,
|
||||
url: url,
|
||||
size: audioBlob.size,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
this.recordings.push(recording);
|
||||
this.updateRecordingsTree();
|
||||
this.showStatus(`Recording saved as ${filename}`, 'success');
|
||||
}
|
||||
|
||||
playLastRecording() {
|
||||
if (this.recordings.length === 0) {
|
||||
this.showStatus('No recordings available', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastRecording = this.recordings[this.recordings.length - 1];
|
||||
const audio = new Audio(lastRecording.url);
|
||||
audio.play();
|
||||
this.showStatus(`Playing ${lastRecording.name}`, 'info');
|
||||
}
|
||||
|
||||
// File Management Functions
|
||||
showFileUploadModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('fileUploadModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
uploadFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const files = Array.from(fileInput.files);
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Simulate upload progress
|
||||
const progressContainer = document.getElementById('uploadProgress');
|
||||
const progressBar = progressContainer.querySelector('.progress-bar');
|
||||
|
||||
progressContainer.style.display = 'block';
|
||||
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += Math.random() * 20;
|
||||
if (progress >= 100) {
|
||||
progress = 100;
|
||||
clearInterval(interval);
|
||||
|
||||
// Add files to selected files
|
||||
this.selectedFiles = [...this.selectedFiles, ...files];
|
||||
this.showStatus(`${files.length} file(s) attached`, 'success');
|
||||
|
||||
// Close modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('fileUploadModal')).hide();
|
||||
progressContainer.style.display = 'none';
|
||||
progressBar.style.width = '0%';
|
||||
fileInput.value = '';
|
||||
}
|
||||
progressBar.style.width = progress + '%';
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Tree and Context Menu Functions
|
||||
loadRecordings() {
|
||||
// Load sample recordings (replace with actual data loading)
|
||||
this.recordings = [
|
||||
{ name: 'sample1.mp3', size: 2097152, timestamp: new Date() },
|
||||
{ name: 'sample2.wav', size: 5242880, timestamp: new Date() }
|
||||
];
|
||||
this.updateRecordingsTree();
|
||||
}
|
||||
|
||||
updateRecordingsTree() {
|
||||
const tree = document.getElementById('explorerTree');
|
||||
const childrenContainer = tree.querySelector('.tree-item-children');
|
||||
|
||||
// Clear existing items except samples
|
||||
childrenContainer.innerHTML = '';
|
||||
|
||||
// Add recordings
|
||||
this.recordings.forEach(recording => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tree-item file';
|
||||
item.dataset.path = `/${recording.name}`;
|
||||
item.innerHTML = `
|
||||
<div class="tree-item-content">
|
||||
<i class="bi bi-file-earmark-music"></i>
|
||||
<span class="tree-item-name">${recording.name}</span>
|
||||
<span class="tree-item-size">${this.formatFileSize(recording.size)}</span>
|
||||
</div>
|
||||
`;
|
||||
childrenContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
handleTreeItemClick(e) {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.tree-item-content.selected').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to clicked item
|
||||
e.target.closest('.tree-item-content').classList.add('selected');
|
||||
}
|
||||
|
||||
setupContextMenu() {
|
||||
const contextMenu = document.getElementById('contextMenu');
|
||||
|
||||
contextMenu.addEventListener('click', (e) => {
|
||||
const action = e.target.dataset.action;
|
||||
if (action) {
|
||||
this.handleContextAction(action);
|
||||
this.hideContextMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showContextMenu(e) {
|
||||
const contextMenu = document.getElementById('contextMenu');
|
||||
contextMenu.style.display = 'block';
|
||||
contextMenu.style.left = e.pageX + 'px';
|
||||
contextMenu.style.top = e.pageY + 'px';
|
||||
}
|
||||
|
||||
hideContextMenu() {
|
||||
document.getElementById('contextMenu').style.display = 'none';
|
||||
}
|
||||
|
||||
handleContextAction(action) {
|
||||
const selectedItem = document.querySelector('.tree-item-content.selected');
|
||||
if (!selectedItem) return;
|
||||
|
||||
const filename = selectedItem.querySelector('.tree-item-name').textContent;
|
||||
|
||||
switch (action) {
|
||||
case 'transcribe':
|
||||
this.showStatus(`Transcribing ${filename}...`, 'info');
|
||||
break;
|
||||
case 'translate':
|
||||
this.showStatus(`Translating ${filename}...`, 'info');
|
||||
break;
|
||||
case 'open':
|
||||
this.showStatus(`Opening ${filename}...`, 'info');
|
||||
break;
|
||||
case 'move':
|
||||
this.showStatus(`Moving ${filename}...`, 'info');
|
||||
break;
|
||||
case 'rename':
|
||||
this.renameFile(filename);
|
||||
break;
|
||||
case 'export':
|
||||
this.exportFile(filename);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renameFile(oldName) {
|
||||
const newName = prompt('Enter new name:', oldName);
|
||||
if (newName && newName !== oldName) {
|
||||
this.showStatus(`Renamed ${oldName} to ${newName}`, 'success');
|
||||
// Update the recording name in the array and refresh tree
|
||||
const recording = this.recordings.find(r => r.name === oldName);
|
||||
if (recording) {
|
||||
recording.name = newName;
|
||||
this.updateRecordingsTree();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exportFile(filename) {
|
||||
const recording = this.recordings.find(r => r.name === filename);
|
||||
if (recording && recording.url) {
|
||||
const a = document.createElement('a');
|
||||
a.href = recording.url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
this.showStatus(`Exported ${filename}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
createNewFolder() {
|
||||
const folderName = prompt('Enter folder name:');
|
||||
if (folderName) {
|
||||
this.showStatus(`Created folder: ${folderName}`, 'success');
|
||||
// In real implementation, create folder in tree
|
||||
}
|
||||
}
|
||||
|
||||
refreshRecordings() {
|
||||
this.showStatus('Refreshing recordings...', 'info');
|
||||
this.loadRecordings();
|
||||
}
|
||||
|
||||
// Voice Input Functions
|
||||
toggleVoiceInput() {
|
||||
const button = document.getElementById('voiceInput');
|
||||
|
||||
if (button.classList.contains('voice-input-active')) {
|
||||
this.stopVoiceInput();
|
||||
} else {
|
||||
this.startVoiceInput();
|
||||
}
|
||||
}
|
||||
|
||||
startVoiceInput() {
|
||||
const button = document.getElementById('voiceInput');
|
||||
button.classList.add('voice-input-active');
|
||||
button.innerHTML = '<i class="bi bi-mic-fill"></i>';
|
||||
this.showStatus('Listening...', 'info');
|
||||
|
||||
// Simulate voice recognition (replace with actual implementation)
|
||||
setTimeout(() => {
|
||||
this.stopVoiceInput();
|
||||
document.getElementById('chatInput').value = 'This is a voice input example';
|
||||
this.autoResizeTextarea();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
stopVoiceInput() {
|
||||
const button = document.getElementById('voiceInput');
|
||||
button.classList.remove('voice-input-active');
|
||||
button.innerHTML = '<i class="bi bi-mic"></i>';
|
||||
this.showStatus('Voice input stopped', 'info');
|
||||
}
|
||||
|
||||
// Utility Functions
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
showStatus(message, type = 'info') {
|
||||
const statusElement = document.getElementById('chatStatus');
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `chat-status text-${type === 'error' ? 'danger' : type === 'success' ? 'success' : type === 'warning' ? 'warning' : 'info'}`;
|
||||
|
||||
// Clear status after 3 seconds
|
||||
setTimeout(() => {
|
||||
statusElement.textContent = '';
|
||||
statusElement.className = 'chat-status';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize chat widget when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.chatWidget = new ChatWidget();
|
||||
});
|
||||
|
||||
// Export for external use
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ChatWidget;
|
||||
}
|
||||
576
lib/web/ui/static/js/heroprompt.js
Normal file
576
lib/web/ui/static/js/heroprompt.js
Normal file
@@ -0,0 +1,576 @@
|
||||
console.log('Heroprompt UI loaded');
|
||||
|
||||
let currentWs = localStorage.getItem('heroprompt-current-ws') || 'default';
|
||||
let selected = [];
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
async function api(url) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {
|
||||
console.warn(`API call failed: ${url} - ${r.status}`);
|
||||
return { error: `HTTP ${r.status}` };
|
||||
}
|
||||
return await r.json();
|
||||
}
|
||||
catch (e) {
|
||||
console.warn(`API call error: ${url}`, e);
|
||||
return { error: 'request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
async function post(url, data) {
|
||||
const form = new FormData();
|
||||
Object.entries(data).forEach(([k, v]) => form.append(k, v));
|
||||
try {
|
||||
const r = await fetch(url, { method: 'POST', body: form });
|
||||
if (!r.ok) {
|
||||
console.warn(`POST failed: ${url} - ${r.status}`);
|
||||
return { error: `HTTP ${r.status}` };
|
||||
}
|
||||
return await r.json();
|
||||
}
|
||||
catch (e) {
|
||||
console.warn(`POST error: ${url}`, e);
|
||||
return { error: 'request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap modal helpers
|
||||
function showModal(id) {
|
||||
const modalEl = el(id);
|
||||
if (modalEl) {
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
function hideModal(id) {
|
||||
const modalEl = el(id);
|
||||
if (modalEl) {
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Tab switching with Bootstrap
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab panes
|
||||
document.querySelectorAll('.tab-pane').forEach(pane => {
|
||||
pane.style.display = 'none';
|
||||
pane.classList.remove('active');
|
||||
});
|
||||
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab pane
|
||||
const targetPane = el(`tab-${tabName}`);
|
||||
if (targetPane) {
|
||||
targetPane.style.display = 'block';
|
||||
targetPane.classList.add('active');
|
||||
}
|
||||
|
||||
// Add active class to clicked tab
|
||||
const targetTab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||
if (targetTab) {
|
||||
targetTab.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tab switching
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const tabName = this.getAttribute('data-tab');
|
||||
switchTab(tabName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Checkbox-based collapsible tree
|
||||
let nodeId = 0;
|
||||
|
||||
function renderTree(displayName, fullPath) {
|
||||
const c = document.createElement('div');
|
||||
c.className = 'tree';
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'tree-root list-unstyled';
|
||||
const root = buildDirNode(displayName, fullPath, true);
|
||||
ul.appendChild(root);
|
||||
c.appendChild(ul);
|
||||
return c;
|
||||
}
|
||||
|
||||
function buildDirNode(name, fullPath, expanded = false) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'dir mb-1';
|
||||
const id = `tn_${nodeId++}`;
|
||||
|
||||
const toggle = document.createElement('input');
|
||||
toggle.type = 'checkbox';
|
||||
toggle.className = 'toggle d-none';
|
||||
toggle.id = id;
|
||||
if (expanded) toggle.checked = true;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = id;
|
||||
label.className = 'dir-label d-flex align-items-center text-decoration-none';
|
||||
label.style.cursor = 'pointer';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'chev me-1';
|
||||
icon.innerHTML = expanded ? '📂' : '📁';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'name flex-grow-1';
|
||||
text.textContent = name;
|
||||
|
||||
label.appendChild(icon);
|
||||
label.appendChild(text);
|
||||
|
||||
const add = document.createElement('button');
|
||||
add.className = 'btn btn-sm btn-outline-primary ms-1';
|
||||
add.textContent = '+';
|
||||
add.title = 'Add directory to selection';
|
||||
add.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
addDirToSelection(fullPath);
|
||||
};
|
||||
|
||||
const children = document.createElement('ul');
|
||||
children.className = 'children list-unstyled ms-3';
|
||||
children.style.display = expanded ? 'block' : 'none';
|
||||
|
||||
toggle.addEventListener('change', async () => {
|
||||
if (toggle.checked) {
|
||||
children.style.display = 'block';
|
||||
icon.innerHTML = '📂';
|
||||
if (!li.dataset.loaded) {
|
||||
await loadChildren(fullPath, children);
|
||||
li.dataset.loaded = '1';
|
||||
}
|
||||
} else {
|
||||
children.style.display = 'none';
|
||||
icon.innerHTML = '📁';
|
||||
}
|
||||
});
|
||||
|
||||
// Load immediately if expanded by default
|
||||
if (expanded) {
|
||||
setTimeout(async () => {
|
||||
await loadChildren(fullPath, children);
|
||||
li.dataset.loaded = '1';
|
||||
}, 0);
|
||||
}
|
||||
|
||||
li.appendChild(toggle);
|
||||
li.appendChild(label);
|
||||
li.appendChild(add);
|
||||
li.appendChild(children);
|
||||
return li;
|
||||
}
|
||||
|
||||
function createFileNode(name, fullPath) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'file d-flex align-items-center mb-1';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'me-2';
|
||||
icon.innerHTML = '📄';
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = '#';
|
||||
a.className = 'text-decoration-none flex-grow-1';
|
||||
a.textContent = name;
|
||||
a.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
previewFile(fullPath);
|
||||
};
|
||||
|
||||
const add = document.createElement('button');
|
||||
add.className = 'btn btn-sm btn-outline-primary ms-1';
|
||||
add.textContent = '+';
|
||||
add.title = 'Add file to selection';
|
||||
add.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
addFileToSelection(fullPath);
|
||||
};
|
||||
|
||||
li.appendChild(icon);
|
||||
li.appendChild(a);
|
||||
li.appendChild(add);
|
||||
return li;
|
||||
}
|
||||
|
||||
async function previewFile(filePath) {
|
||||
const previewEl = el('preview');
|
||||
if (!previewEl) return;
|
||||
|
||||
previewEl.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
const r = await api(`/api/heroprompt/file?name=${currentWs}&path=${encodeURIComponent(filePath)}`);
|
||||
if (r.error) {
|
||||
previewEl.innerHTML = `<div class="error-message">Error: ${r.error}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
previewEl.textContent = r.content || 'No content';
|
||||
}
|
||||
|
||||
async function loadChildren(parentPath, ul) {
|
||||
const r = await api(`/api/heroprompt/directory?name=${currentWs}&path=${encodeURIComponent(parentPath)}`);
|
||||
if (r.error) {
|
||||
ul.innerHTML = `<li class="text-danger small">${r.error}</li>`;
|
||||
return;
|
||||
}
|
||||
ul.innerHTML = '';
|
||||
for (const it of r.items || []) {
|
||||
const full = parentPath.endsWith('/') ? parentPath + it.name : parentPath + '/' + it.name;
|
||||
if (it.type === 'directory') {
|
||||
ul.appendChild(buildDirNode(it.name, full, false));
|
||||
} else {
|
||||
ul.appendChild(createFileNode(it.name, full));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDir(p) {
|
||||
const treeEl = el('tree');
|
||||
if (!treeEl) return;
|
||||
|
||||
treeEl.innerHTML = '<div class="loading">Loading workspace...</div>';
|
||||
const display = p.split('/').filter(Boolean).slice(-1)[0] || p;
|
||||
treeEl.appendChild(renderTree(display, p));
|
||||
updateSelectionList();
|
||||
}
|
||||
|
||||
function updateSelectionList() {
|
||||
const selCountEl = el('selCount');
|
||||
const tokenCountEl = el('tokenCount');
|
||||
const selectedEl = el('selected');
|
||||
|
||||
if (selCountEl) selCountEl.textContent = String(selected.length);
|
||||
if (selectedEl) {
|
||||
selectedEl.innerHTML = '';
|
||||
if (selected.length === 0) {
|
||||
selectedEl.innerHTML = '<li class="text-muted small">No files selected</li>';
|
||||
} else {
|
||||
for (const p of selected) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'd-flex justify-content-between align-items-center mb-1 p-2 border rounded';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'small';
|
||||
span.textContent = p;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-outline-danger';
|
||||
btn.textContent = '×';
|
||||
btn.onclick = () => {
|
||||
selected = selected.filter(x => x !== p);
|
||||
updateSelectionList();
|
||||
};
|
||||
|
||||
li.appendChild(span);
|
||||
li.appendChild(btn);
|
||||
selectedEl.appendChild(li);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// naive token estimator ~ 4 chars/token
|
||||
const tokens = Math.ceil(selected.join('\n').length / 4);
|
||||
if (tokenCountEl) tokenCountEl.textContent = String(Math.ceil(tokens));
|
||||
}
|
||||
|
||||
function addToSelection(p) {
|
||||
if (!selected.includes(p)) {
|
||||
selected.push(p);
|
||||
updateSelectionList();
|
||||
}
|
||||
}
|
||||
|
||||
async function addDirToSelection(p) {
|
||||
const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/dirs`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ path: p })
|
||||
});
|
||||
const j = await r.json().catch(() => ({ error: 'request failed' }));
|
||||
if (j && j.ok !== false && !j.error) {
|
||||
if (!selected.includes(p)) selected.push(p);
|
||||
updateSelectionList();
|
||||
} else {
|
||||
console.warn('Failed to add directory:', j.error || 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function addFileToSelection(p) {
|
||||
if (selected.includes(p)) return;
|
||||
const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/files`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ path: p })
|
||||
});
|
||||
const j = await r.json().catch(() => ({ error: 'request failed' }));
|
||||
if (j && j.ok !== false && !j.error) {
|
||||
selected.push(p);
|
||||
updateSelectionList();
|
||||
} else {
|
||||
console.warn('Failed to add file:', j.error || 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
// Workspaces list + selector
|
||||
async function reloadWorkspaces() {
|
||||
const sel = el('workspaceSelect');
|
||||
if (!sel) return;
|
||||
|
||||
sel.innerHTML = '<option>Loading...</option>';
|
||||
const names = await api('/api/heroprompt/workspaces');
|
||||
|
||||
sel.innerHTML = '';
|
||||
if (names.error || !Array.isArray(names)) {
|
||||
sel.innerHTML = '<option>Error loading workspaces</option>';
|
||||
console.warn('Failed to load workspaces:', names);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const n of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n;
|
||||
opt.textContent = n;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
// ensure current ws name exists or select first
|
||||
if (names.includes(currentWs)) {
|
||||
sel.value = currentWs;
|
||||
} else if (names.length > 0) {
|
||||
currentWs = names[0];
|
||||
sel.value = currentWs;
|
||||
localStorage.setItem('heroprompt-current-ws', currentWs);
|
||||
}
|
||||
}
|
||||
|
||||
// On initial load: pick current or first workspace and load its base
|
||||
async function initWorkspace() {
|
||||
const names = await api('/api/heroprompt/workspaces');
|
||||
if (names.error || !Array.isArray(names) || names.length === 0) {
|
||||
console.warn('No workspaces available');
|
||||
const treeEl = el('tree');
|
||||
if (treeEl) {
|
||||
treeEl.innerHTML = '<div class="text-muted small">No workspaces available. Create one to get started.</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentWs || !names.includes(currentWs)) {
|
||||
currentWs = names[0];
|
||||
localStorage.setItem('heroprompt-current-ws', currentWs);
|
||||
}
|
||||
|
||||
const sel = el('workspaceSelect');
|
||||
if (sel) sel.value = currentWs;
|
||||
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
}
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize workspaces
|
||||
initWorkspace();
|
||||
reloadWorkspaces();
|
||||
|
||||
// Workspace selector change handler
|
||||
const workspaceSelect = el('workspaceSelect');
|
||||
if (workspaceSelect) {
|
||||
workspaceSelect.addEventListener('change', async (e) => {
|
||||
currentWs = e.target.value;
|
||||
localStorage.setItem('heroprompt-current-ws', currentWs);
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
});
|
||||
}
|
||||
|
||||
// Create workspace modal handlers
|
||||
const wsCreateBtn = el('wsCreateBtn');
|
||||
if (wsCreateBtn) {
|
||||
wsCreateBtn.addEventListener('click', () => {
|
||||
const nameEl = el('wcName');
|
||||
const pathEl = el('wcPath');
|
||||
const errorEl = el('wcError');
|
||||
|
||||
if (nameEl) nameEl.value = '';
|
||||
if (pathEl) pathEl.value = '';
|
||||
if (errorEl) errorEl.textContent = '';
|
||||
|
||||
showModal('wsCreate');
|
||||
});
|
||||
}
|
||||
|
||||
const wcCreateBtn = el('wcCreate');
|
||||
if (wcCreateBtn) {
|
||||
wcCreateBtn.addEventListener('click', async () => {
|
||||
const name = el('wcName')?.value?.trim() || '';
|
||||
const path = el('wcPath')?.value?.trim() || '';
|
||||
const errorEl = el('wcError');
|
||||
|
||||
if (!path) {
|
||||
if (errorEl) errorEl.textContent = 'Path is required.';
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = { base_path: path };
|
||||
if (name) formData.name = name;
|
||||
|
||||
const resp = await post('/api/heroprompt/workspaces', formData);
|
||||
if (resp.error) {
|
||||
if (errorEl) errorEl.textContent = resp.error;
|
||||
return;
|
||||
}
|
||||
|
||||
currentWs = resp.name || currentWs;
|
||||
localStorage.setItem('heroprompt-current-ws', currentWs);
|
||||
await reloadWorkspaces();
|
||||
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
|
||||
hideModal('wsCreate');
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh workspace handler
|
||||
const refreshBtn = el('refreshWs');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', async () => {
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
});
|
||||
}
|
||||
|
||||
// Search handler
|
||||
const searchBtn = el('doSearch');
|
||||
if (searchBtn) {
|
||||
searchBtn.onclick = async () => {
|
||||
const q = el('search')?.value?.trim();
|
||||
if (!q) return;
|
||||
|
||||
// For now, just show a message since search endpoint might not exist
|
||||
const tree = el('tree');
|
||||
if (tree) {
|
||||
tree.innerHTML = '<div class="text-muted small">Search functionality coming soon...</div>';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Copy prompt handler
|
||||
const copyPromptBtn = el('copyPrompt');
|
||||
if (copyPromptBtn) {
|
||||
copyPromptBtn.addEventListener('click', async () => {
|
||||
const text = el('promptText')?.value || '';
|
||||
try {
|
||||
const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/prompt`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ text })
|
||||
});
|
||||
const out = await r.text();
|
||||
await navigator.clipboard.writeText(out);
|
||||
|
||||
// Show success feedback
|
||||
const outputEl = el('promptOutput');
|
||||
if (outputEl) {
|
||||
outputEl.innerHTML = '<div class="success-message">Prompt copied to clipboard!</div>';
|
||||
setTimeout(() => {
|
||||
outputEl.innerHTML = '<div class="text-muted small">Generated prompt will appear here</div>';
|
||||
}, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('copy prompt failed', e);
|
||||
const outputEl = el('promptOutput');
|
||||
if (outputEl) {
|
||||
outputEl.innerHTML = '<div class="error-message">Failed to copy prompt</div>';
|
||||
setTimeout(() => {
|
||||
outputEl.innerHTML = '<div class="text-muted small">Generated prompt will appear here</div>';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace details modal handler
|
||||
const wsDetailsBtn = el('wsDetailsBtn');
|
||||
if (wsDetailsBtn) {
|
||||
wsDetailsBtn.addEventListener('click', async () => {
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
if (info && !info.error) {
|
||||
const nameEl = el('wdName');
|
||||
const pathEl = el('wdPath');
|
||||
const errorEl = el('wdError');
|
||||
|
||||
if (nameEl) nameEl.value = info.name || currentWs;
|
||||
if (pathEl) pathEl.value = info.base_path || '';
|
||||
if (errorEl) errorEl.textContent = '';
|
||||
|
||||
showModal('wsDetails');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace manage modal handler
|
||||
const openWsManageBtn = el('openWsManage');
|
||||
if (openWsManageBtn) {
|
||||
openWsManageBtn.addEventListener('click', async () => {
|
||||
const list = el('wmList');
|
||||
const err = el('wmError');
|
||||
if (!list) return;
|
||||
|
||||
if (err) err.textContent = '';
|
||||
list.innerHTML = '<div class="text-muted">Loading workspaces...</div>';
|
||||
|
||||
const names = await api('/api/heroprompt/workspaces');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (names.error || !Array.isArray(names)) {
|
||||
list.innerHTML = '<div class="error-message">Failed to load workspaces</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const n of names) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = n;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-primary';
|
||||
btn.textContent = 'Use';
|
||||
btn.onclick = async () => {
|
||||
currentWs = n;
|
||||
localStorage.setItem('heroprompt-current-ws', currentWs);
|
||||
await reloadWorkspaces();
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
hideModal('wsManage');
|
||||
};
|
||||
|
||||
item.appendChild(span);
|
||||
item.appendChild(btn);
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
showModal('wsManage');
|
||||
});
|
||||
}
|
||||
});
|
||||
867
lib/web/ui/static/js/heroscript.js
Normal file
867
lib/web/ui/static/js/heroscript.js
Normal file
@@ -0,0 +1,867 @@
|
||||
/**
|
||||
* HeroScript Editor JavaScript
|
||||
* Handles code editing, syntax highlighting, script execution, and real-time logging
|
||||
*/
|
||||
|
||||
class ResizablePanel {
|
||||
constructor() {
|
||||
this.isResizing = false;
|
||||
this.resizeDirection = 'horizontal'; // 'horizontal' or 'vertical'
|
||||
this.startX = 0;
|
||||
this.startY = 0;
|
||||
this.startWidth = 0;
|
||||
this.startHeight = 0;
|
||||
this.editorPanel = null;
|
||||
this.logsPanel = null;
|
||||
this.horizontalHandle = null;
|
||||
this.verticalHandle = null;
|
||||
this.currentLayout = 'horizontal'; // 'horizontal' or 'vertical'
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.editorPanel = document.getElementById('editor-panel');
|
||||
this.logsPanel = document.getElementById('logs-panel');
|
||||
this.horizontalHandle = document.getElementById('horizontal-resize-handle');
|
||||
this.verticalHandle = document.getElementById('vertical-resize-handle');
|
||||
|
||||
if (!this.editorPanel || !this.logsPanel) {
|
||||
console.warn('ResizablePanel: Required elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.detectLayout();
|
||||
this.setupResizeHandlers();
|
||||
this.loadSavedSizes();
|
||||
|
||||
// Listen for window resize to detect layout changes
|
||||
window.addEventListener('resize', () => {
|
||||
this.detectLayout();
|
||||
});
|
||||
}
|
||||
|
||||
detectLayout() {
|
||||
const containerStyle = window.getComputedStyle(this.editorPanel.parentElement);
|
||||
const isVertical = containerStyle.flexDirection === 'column';
|
||||
|
||||
this.currentLayout = isVertical ? 'vertical' : 'horizontal';
|
||||
|
||||
// Show/hide appropriate resize handles
|
||||
if (this.horizontalHandle) {
|
||||
this.horizontalHandle.style.display = isVertical ? 'none' : 'flex';
|
||||
}
|
||||
if (this.verticalHandle) {
|
||||
this.verticalHandle.style.display = isVertical ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
setupResizeHandlers() {
|
||||
// Setup horizontal resize handle
|
||||
if (this.horizontalHandle) {
|
||||
this.horizontalHandle.addEventListener('mousedown', (e) => this.startResize(e, 'horizontal'));
|
||||
this.horizontalHandle.addEventListener('touchstart', (e) => this.startResize(e.touches[0], 'horizontal'));
|
||||
this.horizontalHandle.addEventListener('selectstart', (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
// Setup vertical resize handle
|
||||
if (this.verticalHandle) {
|
||||
this.verticalHandle.addEventListener('mousedown', (e) => this.startResize(e, 'vertical'));
|
||||
this.verticalHandle.addEventListener('touchstart', (e) => this.startResize(e.touches[0], 'vertical'));
|
||||
this.verticalHandle.addEventListener('selectstart', (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
// Global mouse/touch events
|
||||
document.addEventListener('mousemove', (e) => this.doResize(e));
|
||||
document.addEventListener('mouseup', () => this.stopResize());
|
||||
document.addEventListener('touchmove', (e) => this.doResize(e.touches[0]));
|
||||
document.addEventListener('touchend', () => this.stopResize());
|
||||
}
|
||||
|
||||
startResize(e, direction) {
|
||||
this.isResizing = true;
|
||||
this.resizeDirection = direction;
|
||||
this.startX = e.clientX;
|
||||
this.startY = e.clientY;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
this.startWidth = parseInt(window.getComputedStyle(this.logsPanel).width, 10);
|
||||
this.horizontalHandle?.classList.add('dragging');
|
||||
} else {
|
||||
this.startHeight = parseInt(window.getComputedStyle(this.logsPanel).height, 10);
|
||||
this.verticalHandle?.classList.add('dragging');
|
||||
}
|
||||
|
||||
document.body.classList.add('resizing');
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
doResize(e) {
|
||||
if (!this.isResizing) return;
|
||||
|
||||
if (this.resizeDirection === 'horizontal') {
|
||||
this.doHorizontalResize(e);
|
||||
} else {
|
||||
this.doVerticalResize(e);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
doHorizontalResize(e) {
|
||||
const deltaX = this.startX - e.clientX; // Reversed because we're resizing from right
|
||||
const newWidth = this.startWidth + deltaX;
|
||||
|
||||
// Get container width for percentage calculation
|
||||
const containerWidth = this.editorPanel.parentElement.offsetWidth;
|
||||
const minWidth = 200; // Minimum width for logs panel
|
||||
const maxWidth = Math.min(600, containerWidth * 0.6); // Maximum 60% of container
|
||||
|
||||
// Constrain the width
|
||||
const constrainedWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
|
||||
|
||||
// Apply the new width
|
||||
this.logsPanel.style.width = constrainedWidth + 'px';
|
||||
|
||||
// Update editor panel flex to fill remaining space
|
||||
const remainingWidth = containerWidth - constrainedWidth - 4; // 4px for resize handle
|
||||
this.editorPanel.style.flex = 'none';
|
||||
this.editorPanel.style.width = remainingWidth + 'px';
|
||||
}
|
||||
|
||||
doVerticalResize(e) {
|
||||
const deltaY = e.clientY - this.startY;
|
||||
const newHeight = this.startHeight + deltaY;
|
||||
|
||||
// Get container height for percentage calculation
|
||||
const containerHeight = this.editorPanel.parentElement.offsetHeight;
|
||||
const minHeight = 150; // Minimum height for logs panel
|
||||
const maxHeight = Math.min(400, containerHeight * 0.6); // Maximum 60% of container
|
||||
|
||||
// Constrain the height
|
||||
const constrainedHeight = Math.max(minHeight, Math.min(maxHeight, newHeight));
|
||||
|
||||
// Apply the new height
|
||||
this.logsPanel.style.height = constrainedHeight + 'px';
|
||||
|
||||
// Update editor panel to fill remaining space
|
||||
const remainingHeight = containerHeight - constrainedHeight - 4; // 4px for resize handle
|
||||
this.editorPanel.style.flex = 'none';
|
||||
this.editorPanel.style.height = remainingHeight + 'px';
|
||||
}
|
||||
|
||||
stopResize() {
|
||||
if (!this.isResizing) return;
|
||||
|
||||
this.isResizing = false;
|
||||
|
||||
// Remove visual feedback
|
||||
this.horizontalHandle?.classList.remove('dragging');
|
||||
this.verticalHandle?.classList.remove('dragging');
|
||||
document.body.classList.remove('resizing');
|
||||
|
||||
// Save the current sizes
|
||||
this.saveSizes();
|
||||
}
|
||||
|
||||
saveSizes() {
|
||||
if (this.currentLayout === 'horizontal') {
|
||||
const logsWidth = this.logsPanel.offsetWidth;
|
||||
const editorWidth = this.editorPanel.offsetWidth;
|
||||
localStorage.setItem('heroscript_logs_width', logsWidth.toString());
|
||||
localStorage.setItem('heroscript_editor_width', editorWidth.toString());
|
||||
} else {
|
||||
const logsHeight = this.logsPanel.offsetHeight;
|
||||
const editorHeight = this.editorPanel.offsetHeight;
|
||||
localStorage.setItem('heroscript_logs_height', logsHeight.toString());
|
||||
localStorage.setItem('heroscript_editor_height', editorHeight.toString());
|
||||
}
|
||||
}
|
||||
|
||||
loadSavedSizes() {
|
||||
if (this.currentLayout === 'horizontal') {
|
||||
const savedLogsWidth = localStorage.getItem('heroscript_logs_width');
|
||||
const savedEditorWidth = localStorage.getItem('heroscript_editor_width');
|
||||
|
||||
if (savedLogsWidth && savedEditorWidth) {
|
||||
const containerWidth = this.editorPanel.parentElement.offsetWidth;
|
||||
const logsWidth = parseInt(savedLogsWidth, 10);
|
||||
const editorWidth = parseInt(savedEditorWidth, 10);
|
||||
|
||||
// Validate that saved sizes fit in current container
|
||||
if (logsWidth + editorWidth + 4 <= containerWidth) {
|
||||
this.logsPanel.style.width = logsWidth + 'px';
|
||||
this.editorPanel.style.flex = 'none';
|
||||
this.editorPanel.style.width = editorWidth + 'px';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const savedLogsHeight = localStorage.getItem('heroscript_logs_height');
|
||||
const savedEditorHeight = localStorage.getItem('heroscript_editor_height');
|
||||
|
||||
if (savedLogsHeight && savedEditorHeight) {
|
||||
const containerHeight = this.editorPanel.parentElement.offsetHeight;
|
||||
const logsHeight = parseInt(savedLogsHeight, 10);
|
||||
const editorHeight = parseInt(savedEditorHeight, 10);
|
||||
|
||||
// Validate that saved sizes fit in current container
|
||||
if (logsHeight + editorHeight + 4 <= containerHeight) {
|
||||
this.logsPanel.style.height = logsHeight + 'px';
|
||||
this.editorPanel.style.flex = 'none';
|
||||
this.editorPanel.style.height = editorHeight + 'px';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetToDefault() {
|
||||
if (this.currentLayout === 'horizontal') {
|
||||
// Reset to default horizontal sizes
|
||||
this.logsPanel.style.width = '350px';
|
||||
this.logsPanel.style.height = 'auto';
|
||||
this.editorPanel.style.flex = '1';
|
||||
this.editorPanel.style.width = 'auto';
|
||||
this.editorPanel.style.height = 'auto';
|
||||
|
||||
// Clear saved horizontal sizes
|
||||
localStorage.removeItem('heroscript_logs_width');
|
||||
localStorage.removeItem('heroscript_editor_width');
|
||||
} else {
|
||||
// Reset to default vertical sizes
|
||||
this.logsPanel.style.height = '250px';
|
||||
this.logsPanel.style.width = '100%';
|
||||
this.editorPanel.style.flex = '1';
|
||||
this.editorPanel.style.height = 'auto';
|
||||
this.editorPanel.style.width = 'auto';
|
||||
|
||||
// Clear saved vertical sizes
|
||||
localStorage.removeItem('heroscript_logs_height');
|
||||
localStorage.removeItem('heroscript_editor_height');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HeroScriptEditor {
|
||||
constructor() {
|
||||
this.editor = null;
|
||||
this.logsContainer = null;
|
||||
this.autoScroll = true;
|
||||
this.redisConnection = null;
|
||||
this.currentSyntax = 'javascript';
|
||||
this.resizablePanel = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the HeroScript editor
|
||||
*/
|
||||
init() {
|
||||
this.setupEditor();
|
||||
this.setupLogging();
|
||||
this.setupEventListeners();
|
||||
this.connectToRedis();
|
||||
this.applySyntaxHighlighting();
|
||||
this.setupResizablePanels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup resizable panels
|
||||
*/
|
||||
setupResizablePanels() {
|
||||
this.resizablePanel = new ResizablePanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the code editor
|
||||
*/
|
||||
setupEditor() {
|
||||
this.editor = document.getElementById('script-editor');
|
||||
this.logsContainer = document.getElementById('logs-content');
|
||||
|
||||
if (!this.editor || !this.logsContainer) {
|
||||
console.error('HeroScript: Required elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial content if empty
|
||||
if (!this.editor.value.trim()) {
|
||||
this.editor.value = this.getDefaultScript();
|
||||
}
|
||||
|
||||
// Setup basic editor features
|
||||
this.setupEditorFeatures();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup syntax highlighting
|
||||
*/
|
||||
setupEditorFeatures() {
|
||||
// Handle syntax selection change
|
||||
const syntaxSelect = document.getElementById('syntax-select');
|
||||
if (syntaxSelect) {
|
||||
syntaxSelect.addEventListener('change', (e) => {
|
||||
this.currentSyntax = e.target.value;
|
||||
this.addLogEntry('user', `Syntax changed to ${this.currentSyntax}`, 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// Add tab support for better code editing
|
||||
this.editor.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = this.editor.selectionStart;
|
||||
const end = this.editor.selectionEnd;
|
||||
|
||||
// Insert tab character
|
||||
this.editor.value = this.editor.value.substring(0, start) +
|
||||
' ' +
|
||||
this.editor.value.substring(end);
|
||||
|
||||
// Move cursor
|
||||
this.editor.selectionStart = this.editor.selectionEnd = start + 4;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize editor based on content
|
||||
this.editor.addEventListener('input', () => {
|
||||
this.autoResizeEditor();
|
||||
});
|
||||
|
||||
// Note: Scroll sync not needed with contenteditable approach
|
||||
|
||||
// Initial resize
|
||||
this.autoResizeEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-resize editor to fit content
|
||||
*/
|
||||
autoResizeEditor() {
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
this.editor.style.height = 'auto';
|
||||
|
||||
// Set height based on content, with minimum height
|
||||
const minHeight = 500;
|
||||
const contentHeight = this.editor.scrollHeight;
|
||||
this.editor.style.height = Math.max(minHeight, contentHeight) + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply syntax highlighting to the editor content
|
||||
*/
|
||||
applySyntaxHighlighting() {
|
||||
// Replace textarea with a contenteditable div for better highlighting
|
||||
this.replaceTextareaWithHighlightedEditor();
|
||||
|
||||
// Update highlighting when syntax changes
|
||||
const syntaxSelect = document.getElementById('syntax-select');
|
||||
if (syntaxSelect) {
|
||||
syntaxSelect.addEventListener('change', () => {
|
||||
this.currentSyntax = syntaxSelect.value;
|
||||
this.updateHighlighting();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial highlighting
|
||||
this.updateHighlighting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace textarea with a highlighted contenteditable div
|
||||
*/
|
||||
replaceTextareaWithHighlightedEditor() {
|
||||
const container = document.getElementById('editor-container');
|
||||
if (!container || !this.editor) return;
|
||||
|
||||
// Get current content
|
||||
const content = this.editor.value || this.getDefaultScript();
|
||||
|
||||
// Create new highlighted editor
|
||||
const highlightedEditor = document.createElement('pre');
|
||||
highlightedEditor.id = 'highlighted-editor';
|
||||
highlightedEditor.className = 'hljs';
|
||||
highlightedEditor.style.cssText = `
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
background-color: var(--bg-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-height: 500px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
`;
|
||||
|
||||
const codeElement = document.createElement('code');
|
||||
codeElement.className = `language-${this.currentSyntax}`;
|
||||
codeElement.contentEditable = 'true';
|
||||
codeElement.textContent = content;
|
||||
codeElement.style.cssText = `
|
||||
display: block;
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
`;
|
||||
|
||||
highlightedEditor.appendChild(codeElement);
|
||||
|
||||
// Replace the textarea
|
||||
this.editor.style.display = 'none';
|
||||
container.appendChild(highlightedEditor);
|
||||
|
||||
// Update editor reference
|
||||
this.highlightedEditor = highlightedEditor;
|
||||
this.codeElement = codeElement;
|
||||
|
||||
// Add event listeners for the new editor
|
||||
this.setupHighlightedEditorEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for the highlighted editor
|
||||
*/
|
||||
setupHighlightedEditorEvents() {
|
||||
if (!this.codeElement) return;
|
||||
|
||||
// Update highlighting on input
|
||||
this.codeElement.addEventListener('input', () => {
|
||||
// Update the hidden textarea value
|
||||
this.editor.value = this.codeElement.textContent;
|
||||
|
||||
// Debounce highlighting updates
|
||||
clearTimeout(this.highlightTimeout);
|
||||
this.highlightTimeout = setTimeout(() => {
|
||||
this.updateHighlighting();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Handle tab key for indentation
|
||||
this.codeElement.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
document.execCommand('insertText', false, ' ');
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the new editor
|
||||
this.codeElement.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update syntax highlighting
|
||||
*/
|
||||
updateHighlighting() {
|
||||
if (!this.codeElement || !window.hljs) return;
|
||||
|
||||
const content = this.codeElement.textContent;
|
||||
|
||||
// Store cursor position
|
||||
const selection = window.getSelection();
|
||||
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const cursorOffset = range ? range.startOffset : 0;
|
||||
|
||||
// Update language class
|
||||
this.codeElement.className = `language-${this.currentSyntax}`;
|
||||
|
||||
// Apply highlighting
|
||||
window.hljs.highlightElement(this.codeElement);
|
||||
|
||||
// Restore cursor position
|
||||
if (range && this.codeElement.firstChild) {
|
||||
try {
|
||||
const newRange = document.createRange();
|
||||
const textNode = this.codeElement.firstChild;
|
||||
const maxOffset = textNode.textContent ? textNode.textContent.length : 0;
|
||||
newRange.setStart(textNode, Math.min(cursorOffset, maxOffset));
|
||||
newRange.setEnd(textNode, Math.min(cursorOffset, maxOffset));
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
} catch (e) {
|
||||
// Cursor restoration failed, that's okay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup logging system
|
||||
*/
|
||||
setupLogging() {
|
||||
this.addLogEntry('system', 'HeroScript Editor initialized', 'info');
|
||||
this.addLogEntry('system', 'Connecting to Redis queue: hero.gui.logs', 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Run script button
|
||||
const runButton = document.getElementById('run-script');
|
||||
if (runButton) {
|
||||
runButton.addEventListener('click', () => this.runScript());
|
||||
}
|
||||
|
||||
// Clear logs button
|
||||
const clearButton = document.getElementById('clear-logs');
|
||||
if (clearButton) {
|
||||
clearButton.addEventListener('click', () => this.clearLogs());
|
||||
}
|
||||
|
||||
// Save script button
|
||||
const saveButton = document.getElementById('save-script');
|
||||
if (saveButton) {
|
||||
saveButton.addEventListener('click', () => this.saveScript());
|
||||
}
|
||||
|
||||
// Auto-scroll toggle
|
||||
const autoScrollButton = document.getElementById('auto-scroll');
|
||||
if (autoScrollButton) {
|
||||
autoScrollButton.addEventListener('click', () => this.toggleAutoScroll());
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey)) {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
this.runScript();
|
||||
break;
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
this.saveScript();
|
||||
break;
|
||||
case 'l':
|
||||
e.preventDefault();
|
||||
this.clearLogs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Redis queue for real-time logging
|
||||
*/
|
||||
connectToRedis() {
|
||||
// Simulate Redis connection with WebSocket or Server-Sent Events
|
||||
// In a real implementation, you'd connect to a WebSocket endpoint
|
||||
// that subscribes to the Redis queue
|
||||
|
||||
this.simulateRedisConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate Redis connection for demo purposes
|
||||
*/
|
||||
simulateRedisConnection() {
|
||||
// Update connection status
|
||||
this.updateConnectionStatus('connected');
|
||||
|
||||
// Generate demo logs with different categories
|
||||
const logCategories = [
|
||||
{ cat: 'system', messages: ['service started', 'health check ok', 'memory usage normal'], color: 'blue' },
|
||||
{ cat: 'database', messages: ['connection established', 'query executed', 'backup completed'], color: 'green' },
|
||||
{ cat: 'api', messages: ['request processed', 'rate limit applied', 'cache hit'], color: 'cyan' },
|
||||
{ cat: 'security', messages: ['auth successful', 'token refreshed', 'access granted'], color: 'yellow' },
|
||||
{ cat: 'error', messages: ['connection timeout', 'invalid request', 'service unavailable'], color: 'red' },
|
||||
{ cat: 'warning', messages: ['high memory usage', 'slow query detected', 'retry attempt'], color: 'orange' }
|
||||
];
|
||||
|
||||
// Generate logs every 2-5 seconds
|
||||
setInterval(() => {
|
||||
if (Math.random() < 0.7) { // 70% chance
|
||||
const category = logCategories[Math.floor(Math.random() * logCategories.length)];
|
||||
const message = category.messages[Math.floor(Math.random() * category.messages.length)];
|
||||
this.addCompactLogEntry(category.cat, message, category.color);
|
||||
}
|
||||
}, Math.random() * 3000 + 2000); // 2-5 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status indicator
|
||||
*/
|
||||
updateConnectionStatus(status) {
|
||||
const statusElement = document.getElementById('connection-status');
|
||||
if (!statusElement) return;
|
||||
|
||||
statusElement.className = 'badge';
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
statusElement.classList.add('bg-success');
|
||||
statusElement.textContent = 'Connected';
|
||||
break;
|
||||
case 'connecting':
|
||||
statusElement.classList.add('bg-warning');
|
||||
statusElement.textContent = 'Connecting...';
|
||||
break;
|
||||
case 'disconnected':
|
||||
statusElement.classList.add('bg-danger');
|
||||
statusElement.textContent = 'Disconnected';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the script in the editor
|
||||
*/
|
||||
async runScript() {
|
||||
const script = this.editor.value.trim();
|
||||
if (!script) {
|
||||
this.addLogEntry('user', 'No script to execute', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.addLogEntry('user', 'Starting script execution...', 'info');
|
||||
|
||||
try {
|
||||
// Simulate script execution
|
||||
await this.executeScript(script);
|
||||
} catch (error) {
|
||||
this.addLogEntry('user', `Script execution failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the script (simulation)
|
||||
*/
|
||||
async executeScript(script) {
|
||||
// Simulate script execution with delays
|
||||
const lines = script.split('\n').filter(line => line.trim());
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || line.startsWith('//')) continue;
|
||||
|
||||
// Simulate processing time
|
||||
await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 300));
|
||||
|
||||
// Simulate different types of output
|
||||
if (line.includes('console.log')) {
|
||||
const match = line.match(/console\.log\(['"`](.+?)['"`]\)/);
|
||||
if (match) {
|
||||
this.addLogEntry('script', match[1], 'info');
|
||||
}
|
||||
} else if (line.includes('error') || line.includes('Error')) {
|
||||
this.addLogEntry('script', `Error in line: ${line}`, 'error');
|
||||
} else if (line.includes('warn')) {
|
||||
this.addLogEntry('script', `Warning in line: ${line}`, 'warning');
|
||||
} else {
|
||||
this.addLogEntry('script', `Executed: ${line}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
this.addLogEntry('user', 'Script execution completed', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current script
|
||||
*/
|
||||
saveScript() {
|
||||
const script = this.editor.value;
|
||||
|
||||
// Simulate saving to server
|
||||
this.addLogEntry('user', 'Saving script...', 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// Save to localStorage for demo
|
||||
localStorage.setItem('heroscript_content', script);
|
||||
localStorage.setItem('heroscript_syntax', this.currentSyntax);
|
||||
this.addLogEntry('user', 'Script saved successfully', 'success');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved script
|
||||
*/
|
||||
loadScript() {
|
||||
const savedScript = localStorage.getItem('heroscript_content');
|
||||
const savedSyntax = localStorage.getItem('heroscript_syntax');
|
||||
|
||||
if (savedScript) {
|
||||
this.editor.value = savedScript;
|
||||
this.addLogEntry('user', 'Script loaded from storage', 'info');
|
||||
}
|
||||
|
||||
if (savedSyntax) {
|
||||
this.currentSyntax = savedSyntax;
|
||||
const syntaxSelect = document.getElementById('syntax-select');
|
||||
if (syntaxSelect) {
|
||||
syntaxSelect.value = savedSyntax;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resize after loading
|
||||
this.autoResizeEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logs
|
||||
*/
|
||||
clearLogs() {
|
||||
if (this.logsContainer) {
|
||||
this.logsContainer.innerHTML = '';
|
||||
this.addLogEntry('user', 'Logs cleared', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle auto-scroll functionality
|
||||
*/
|
||||
toggleAutoScroll() {
|
||||
this.autoScroll = !this.autoScroll;
|
||||
const button = document.getElementById('auto-scroll');
|
||||
if (button) {
|
||||
button.setAttribute('data-active', this.autoScroll.toString());
|
||||
button.innerHTML = this.autoScroll
|
||||
? '<i class="fas fa-arrow-down"></i> Auto-scroll'
|
||||
: '<i class="fas fa-pause"></i> Manual';
|
||||
}
|
||||
|
||||
this.addLogEntry('user', `Auto-scroll ${this.autoScroll ? 'enabled' : 'disabled'}`, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a compact log entry in the format: cat: loginfo
|
||||
*/
|
||||
addCompactLogEntry(category, message, color = 'blue') {
|
||||
if (!this.logsContainer) return;
|
||||
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry compact new`;
|
||||
|
||||
logEntry.innerHTML = `
|
||||
<span class="log-category" style="color: ${this.getLogColor(color)}">${this.escapeHtml(category)}:</span>
|
||||
<span class="log-message">${this.escapeHtml(message)}</span>
|
||||
`;
|
||||
|
||||
this.logsContainer.appendChild(logEntry);
|
||||
|
||||
// Remove 'new' class after animation
|
||||
setTimeout(() => {
|
||||
logEntry.classList.remove('new');
|
||||
}, 300);
|
||||
|
||||
// Auto-scroll if enabled
|
||||
if (this.autoScroll) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
// Limit log entries to prevent memory issues
|
||||
const maxEntries = 500;
|
||||
const entries = this.logsContainer.children;
|
||||
if (entries.length > maxEntries) {
|
||||
for (let i = 0; i < entries.length - maxEntries; i++) {
|
||||
entries[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log entry to the logs panel (legacy format for system messages)
|
||||
*/
|
||||
addLogEntry(source, message, level = 'info') {
|
||||
// Use compact format for better display
|
||||
const colorMap = {
|
||||
'info': 'blue',
|
||||
'success': 'green',
|
||||
'warning': 'orange',
|
||||
'error': 'red',
|
||||
'debug': 'gray'
|
||||
};
|
||||
this.addCompactLogEntry(source, message, colorMap[level] || 'blue');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color value for log categories
|
||||
*/
|
||||
getLogColor(colorName) {
|
||||
const colors = {
|
||||
'red': '#ff4444',
|
||||
'blue': '#4488ff',
|
||||
'green': '#44ff44',
|
||||
'yellow': '#ffff44',
|
||||
'orange': '#ff8844',
|
||||
'cyan': '#44ffff',
|
||||
'purple': '#ff44ff',
|
||||
'gray': '#888888'
|
||||
};
|
||||
return colors[colorName] || colors['blue'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll logs to bottom
|
||||
*/
|
||||
scrollToBottom() {
|
||||
const container = document.getElementById('logs-container');
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default script content
|
||||
*/
|
||||
getDefaultScript() {
|
||||
return `// Welcome to HeroScript Editor!
|
||||
// This is a powerful script execution environment
|
||||
|
||||
console.log('Hello from HeroScript!');
|
||||
|
||||
// Example: Simple loop with logging
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
console.log(\`Step \${i}: Processing...\`);
|
||||
}
|
||||
|
||||
// Example: Conditional logic
|
||||
if (new Date().getHours() < 12) {
|
||||
console.log('Good morning!');
|
||||
} else {
|
||||
console.log('Good afternoon!');
|
||||
}
|
||||
|
||||
console.log('Script execution completed!');
|
||||
|
||||
// Try editing this script and click "Run Script" to see it in action
|
||||
// Use Ctrl+Enter to run, Ctrl+S to save, Ctrl+L to clear logs`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize HeroScript Editor when DOM is ready
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Only initialize if we're on the HeroScript page
|
||||
if (document.getElementById('script-editor')) {
|
||||
window.heroScriptEditor = new HeroScriptEditor();
|
||||
|
||||
// Load any saved script
|
||||
window.heroScriptEditor.loadScript();
|
||||
|
||||
console.log('HeroScript Editor initialized');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Export for external use
|
||||
*/
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { HeroScriptEditor };
|
||||
}
|
||||
Reference in New Issue
Block a user