<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Hermes Chat v4 — 流式对话</title>
<!-- Markdown 渲染引擎 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.6/marked.min.js"></script>
<!-- 代码高亮 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans SC', 'PingFang SC', sans-serif;
background: #0a0a0a; color: #e0e0e0; height: 100vh; overflow: hidden;
}
/* ── 整体布局:左 + 右 ── */
#app { display: flex; height: 100vh; }
/* ── 左侧面板(IDEA 风格可折叠多面板) ── */
#sidebar {
width: 320px; min-width: 320px;
display: flex; flex-direction: column;
background: #111; border-right: 1px solid #222;
flex-shrink: 0;
}
#sidebar-header {
padding: 8px 14px; background: #0d0d0d;
border-bottom: 1px solid #222;
font-size: 13px; font-weight: 600; color: #888;
display: flex; align-items: center; gap: 6px;
flex-shrink: 0;
}
#sidebar-header .badge {
font-size: 10px; background: #1a3a2a; color: #4ade80;
padding: 1px 7px; border-radius: 8px;
}
#add-task-btn {
margin-left: auto; background: none; border: 1px solid #333;
color: #888; width: 22px; height: 22px; border-radius: 4px;
cursor: pointer; font-size: 13px; line-height: 1; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
transition: all 0.15s;
}
#add-task-btn:hover { background: #1a3a2a; border-color: #4ade80; color: #4ade80; }
/* IDEA 风格可折叠面板 */
.side-panel { display: flex; flex-direction: column; border-bottom: 1px solid #1a1a1a; }
.side-panel .panel-header {
padding: 6px 14px; cursor: pointer; display: flex; align-items: center;
font-size: 12px; color: #aaa; user-select: none; flex-shrink: 0;
background: #0d0d0d; transition: background 0.15s;
gap: 6px;
}
.side-panel .panel-header:hover { background: #1a1a1a; color: #4ade80; }
.side-panel .panel-header .panel-icon { font-size: 13px; }
.side-panel .panel-header .panel-title { flex: 1; font-weight: 500; }
.side-panel .panel-header .panel-count {
font-size: 10px; color: #555; background: #1a1a1a;
padding: 0 6px; border-radius: 6px;
}
.side-panel .panel-header .panel-arrow {
font-size: 10px; color: #555; transition: transform 0.2s;
}
.side-panel.collapsed .panel-header .panel-arrow { transform: rotate(-90deg); }
.side-panel .panel-body { flex: 1; overflow-y: auto; min-height: 0; }
.side-panel.collapsed .panel-body { display: none; }
.side-panel.active { flex: 1; min-height: 100px; }
.side-panel.active .panel-body { flex: 1; overflow-y: auto; }
.side-panel:not(.active):not(.collapsed) .panel-body { max-height: 200px; overflow-y: auto; }
/* 归属skill面板 */
.skill-assign-item {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; font-size: 12px; color: #ccc;
border-bottom: 1px solid #1a1a1a; cursor: default;
}
.skill-assign-item:hover { background: #1e1e1e; }
.skill-assign-item .sname { flex: 1; }
.skill-assign-item .stask-count { font-size: 10px; color: #666; }
.skill-assign-empty {
padding: 20px; text-align: center; color: #555; font-size: 12px;
}
/* 表单列表 */
.form-item {
display: flex; align-items: center; gap: 6px;
padding: 8px 14px; font-size: 12px; color: #ccc;
border-bottom: 1px solid #1a1a1a; cursor: pointer;
}
.form-item:hover { background: #1e1e1e; }
.form-item .fname { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.form-empty {
padding: 20px; text-align: center; color: #555; font-size: 12px;
}
/* ── 表单抽屉覆写 ── */
.form-overlay {
display: none; position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.7);
justify-content: center; align-items: flex-start;
padding: 20px; overflow-y: auto;
}
.form-overlay.show { display: flex; }
.form-overlay-card {
width: 100%; max-width: 520px; margin: 20px auto;
background: #1a1a2e; border-radius: 12px;
padding: 24px; border: 1px solid #2a2a4a;
}
.form-overlay-title {
font-size: 17px; font-weight: 600; color: #FFD700;
margin-bottom: 20px; line-height: 1.4; padding-right: 30px;
}
.form-overlay-close {
position: absolute; top: 12px; right: 14px;
background: none; border: none; color: #888;
font-size: 20px; cursor: pointer; padding: 4px 8px;
}
.form-overlay-header {
position: relative; border-bottom: 1px solid #2a2a4a;
margin-bottom: 20px; padding-bottom: 12px;
}
/* 表单字段 */
.form-field { margin-bottom: 18px; }
.form-field-label {
font-size: 13px; color: #aaa; margin-bottom: 6px; font-weight: 500;
}
.form-field-label .required { color: #ef5350; }
.form-field-text input {
width: 100%; padding: 10px 12px;
background: #16162a; border: 1px solid #2a2a4a;
border-radius: 6px; color: #e0e0e0; font-size: 15px;
outline: none;
}
.form-field-text input:focus { border-color: #FFD700; }
.form-field-radio {
display: flex; flex-direction: column; gap: 6px;
}
.form-field-radio label {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; background: #16162a;
border: 1px solid #2a2a4a; border-radius: 6px;
cursor: pointer; font-size: 14px;
}
.form-field-radio label:active { background: #1e1e3a; }
.form-field-radio input[type="radio"] {
width: 16px; height: 16px; accent-color: #FFD700; flex-shrink: 0;
}
.form-field-radio label:has(input:checked) { border-color: #FFD700; background: #1a1a30; }
.form-field-radio input[type="radio"]:checked + span { color: #FFD700; }
.form-overlay-btn {
width: 100%; padding: 12px;
background: #FFD700; color: #0a0a0a;
border: none; border-radius: 6px;
font-size: 15px; font-weight: 600; cursor: pointer;
margin-top: 4px;
}
.form-overlay-btn:active { opacity: 0.85; }
.form-overlay-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.form-overlay-saved {
text-align: center; padding: 8px;
color: #4ade80; font-size: 13px; display: none;
}
/* ── Skill 抽屉 ── */
.skill-drawer-overlay {
display: none; position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.7);
justify-content: center; align-items: flex-start;
padding: 20px; overflow-y: auto;
}
.skill-drawer-overlay.show { display: flex; }
.skill-drawer-card {
width: 100%; max-width: 480px; margin: 20px auto;
background: #1a1a2e; border-radius: 12px;
padding: 24px; border: 1px solid #2a2a4a;
}
.skill-drawer-header {
position: relative; border-bottom: 1px solid #2a2a4a;
margin-bottom: 16px; padding-bottom: 12px;
}
.skill-drawer-name { font-size: 16px; font-weight: 600; color: #4ade80; }
.skill-drawer-cat { font-size: 11px; color: #666; margin-top: 2px; }
.skill-drawer-desc { font-size: 13px; color: #ccc; line-height: 1.6; margin-bottom: 12px; }
.skill-drawer-tags {
display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px;
}
.skill-drawer-tag {
font-size: 10px; background: #1a1a2e; color: #888;
border: 1px solid #2a2a4a; padding: 2px 8px; border-radius: 8px;
}
.skill-drawer-tasks { font-size: 12px; color: #555; }
/* ── 新建任务表单 ── */
#new-task-form {
display: none; padding: 6px 14px; border-bottom: 1px solid #222;
background: #0d0d0d; flex-shrink: 0;
}
#new-task-form.open { display: block; }
#new-task-form .form-row {
margin-bottom: 4px; display: flex; flex-direction: column; gap: 3px;
}
#new-task-form label {
font-size: 10px; color: #888;
}
#new-task-form select,
#new-task-form input[type="text"] {
background: #1a1a1a; border: 1px solid #333; border-radius: 4px;
padding: 5px 8px; color: #e0e0e0; font-size: 12px; outline: none;
width: 100%;
}
#new-task-form select:focus,
#new-task-form input[type="text"]:focus {
border-color: #4ade80;
}
#new-task-form .form-actions {
display: flex; gap: 6px; justify-content: flex-end;
}
#new-task-form .form-actions button {
padding: 4px 12px; border-radius: 4px; border: none;
cursor: pointer; font-size: 11px; font-weight: 500;
}
#new-task-form .btn-create {
background: #4ade80; color: #0a0a0a;
}
#new-task-form .btn-create:hover { opacity: 0.85; }
#new-task-form .btn-cancel {
background: #333; color: #aaa;
}
#new-task-form .btn-cancel:hover { background: #444; }
#new-task-form .form-error {
font-size: 11px; color: #ef4444; display: none;
}
#new-task-form .form-error.show { display: block; }
/* ── 任务列表 ── */
#task-list {
overflow-y: auto;
}
.task-item {
padding: 5px 12px; cursor: pointer;
transition: background 0.15s;
display: flex; align-items: center; gap: 6px;
}
.task-item:hover { background: #1a1a2e; }
.task-item.active { background: #1a2a3a; border-left: 3px solid #4ade80; }
.task-status {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
}
.task-status.created { background: #666; }
.task-status.running { background: #4ade80; }
.task-status.paused { background: #f59e0b; }
.task-status.completed { background: #3b82f6; }
.task-status.cancelled { background: #ef4444; }
.task-info { flex: 1; min-width: 0; }
.task-name { font-size: 12px; color: #e0e0e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-meta { font-size: 10px; color: #555; margin-top: 1px; }
.task-status-text { font-size: 10px; color: #777; }
.task-empty {
padding: 14px 12px; text-align: center; color: #444; font-size: 12px;
}
/* ── 任务搜索框 ── */
.task-search-box {
padding: 6px 12px; background: #0d0d0d;
border-bottom: 1px solid #1a1a1a;
}
.task-search-box input {
width: 100%; padding: 5px 10px;
border: 1px solid #2a2a2a; border-radius: 4px;
background: #161616; color: #e0e0e0;
font-size: 11px; outline: none;
}
.task-search-box input:focus { border-color: #4ade80; }
.task-search-box input::placeholder { color: #555; }
/* 任务文件夹树 */
.task-folder {}
.task-folder-header {
padding: 5px 10px; cursor: pointer; display: flex; align-items: center; gap: 4px;
font-size: 12px; color: #888; user-select: none;
}
.task-folder-header:hover { background: #1a1a1a; color: #ccc; }
.task-folder-header .arrow { font-size: 9px; width: 12px; text-align: center; color: #555; transition: transform .15s; }
.task-folder-header .arrow.open { transform: rotate(90deg); }
.task-folder-header .fname { font-weight: 500; }
.task-folder-children { display: none; }
.task-folder.open > .task-folder-children { display: block; }
.task-folder-item {
padding: 5px 8px 5px 22px; cursor: pointer; display: flex; align-items: center; gap: 4px;
font-size: 12px; color: #999; transition: background 0.1s; border-left: 2px solid transparent;
}
.task-folder-item:hover { background: #1a1a1a; color: #4ade80; }
.task-folder-item.active { background: #1a3a2a; color: #4ade80; border-left-color: #4ade80; }
.task-folder-item .dot {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
}
.task-folder-item .dot.active { background: #4caf50; }
.task-folder-item .dot.running { background: #ffa726; }
.task-folder-item .dot.created { background: #666; }
.task-folder-item .dot.completed { background: #888; }
.task-folder-item .tname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-section-title {
font-size: 10px; color: #555; padding: 6px 10px 3px; text-transform: uppercase; letter-spacing: .5px;
}
/* ── 上下分隔条 ── */
#sidebar-divider {
height: 1px; background: #222; cursor: ns-resize; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
position: relative;
}
#sidebar-divider::after {
content: '⋯'; color: #444; font-size: 9px; letter-spacing: 2px;
}
/* ── wiki 知识树 ── */
#wiki-panel {
flex: 1; overflow-y: auto;
background: #0d0d0d;
}
#wiki-header {
padding: 6px 14px; font-size: 11px; color: #666;
border-bottom: 1px solid #1a1a1a;
display: flex; align-items: center; gap: 6px;
flex-shrink: 0; position: sticky; top: 0; background: #0d0d0d;
}
.wiki-doc {
padding: 5px 14px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 6px;
transition: background 0.1s; color: #aaa; border-left: 2px solid transparent;
}
.wiki-doc:hover { background: #1a1a1a; color: #ccc; }
.wiki-doc::before { content: '📄'; font-size: 11px; }
.wiki-doc-empty { padding: 20px 14px; text-align: center; color: #444; font-size: 12px; }
/* ── 附件列表 ── */
.att-item {
padding: 6px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px;
font-size: 12px; color: #aaa; transition: background 0.1s;
border-left: 2px solid transparent;
}
.att-item:hover { background: #1a1a2e; color: #4ade80; border-left-color: #4ade80; }
.att-item .att-icon { font-size: 14px; flex-shrink: 0; }
.att-item .att-info { flex: 1; min-width: 0; }
.att-item .att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.att-item .att-meta { font-size: 10px; color: #555; margin-top: 1px; }
.att-item .att-size { font-size: 10px; color: #555; flex-shrink: 0; }
.att-empty { padding: 20px 14px; text-align: center; color: #444; font-size: 12px; }
/* ── 附件预览遮罩 ── */
.att-preview-overlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.7); z-index: 200;
justify-content: center; align-items: center;
}
.att-preview-overlay.show { display: flex; }
.att-preview-modal {
background: #1a1a1a; border: 1px solid #333; border-radius: 8px;
width: 80vw; max-width: 900px; max-height: 85vh;
display: flex; flex-direction: column; box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
.att-preview-header {
padding: 10px 16px; border-bottom: 1px solid #222;
display: flex; align-items: center; gap: 8px;
}
.att-preview-header .att-preview-name { flex: 1; font-size: 14px; font-weight: 500; }
.att-preview-header .att-preview-close {
background: none; border: none; color: #666; font-size: 18px;
cursor: pointer; padding: 0 4px;
}
.att-preview-header .att-preview-close:hover { color: #ef4444; }
.att-preview-body {
flex: 1; overflow: auto; padding: 16px;
display: flex; justify-content: center; align-items: flex-start;
}
.att-preview-body img, .att-preview-body video {
max-width: 100%; max-height: 70vh; border-radius: 4px;
}
.att-preview-body iframe {
width: 100%; height: 70vh; border: none;
}
.att-preview-body .att-text-preview {
width: 100%; white-space: pre-wrap; font-size: 13px;
color: #ccc; line-height: 1.6; font-family: monospace;
}
.att-preview-body .att-unsupported {
text-align: center; color: #666; padding: 40px;
}
/* ── 右侧:聊天窗口 ── */
#chat-area {
flex: 1; display: flex; flex-direction: column;
background: #0a0a0a;
}
#chat-header {
padding: 8px 16px; background: #111;
border-bottom: 1px solid #222;
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
#chat-header h1 { font-size: 14px; font-weight: 600; color: #4ade80; }
#chat-header .subtitle { font-size: 11px; color: #666; margin-left: auto; }
#status { font-size: 11px; }
#status.online { color: #4ade80; }
#status.offline { color: #ef4444; }
#no-task-msg {
flex: 1; display: flex; align-items: center; justify-content: center;
color: #555; font-size: 13px;
}
#messages {
flex: 1; overflow-y: auto; padding: 12px;
display: flex; flex-direction: column; gap: 8px;
}
.msg { max-width: 85%; padding: 8px 12px; border-radius: 10px; line-height: 1.5; font-size: 13px; word-wrap: break-word; }
.msg.user { background: #1a3a2a; color: #e0e0e0; align-self: flex-end; border-bottom-right-radius: 3px; }
.msg.ai { background: #1a1a2e; color: #e0e0e0; align-self: flex-start; border-bottom-left-radius: 3px; }
.msg.system { background: #222; color: #888; align-self: center; font-size: 11px; padding: 4px 10px; border-radius: 4px; }
.msg.ai p { margin: 3px 0; }
.msg.ai code { background: #2a2a3e; padding: 1px 5px; border-radius: 3px; font-size: 12px; }
.msg.ai pre { background: #2a2a3e; padding: 8px; border-radius: 6px; overflow-x: auto; margin: 6px 0; font-size: 12px; }
.msg.ai pre code { background: none; padding: 0; }
/* ── 增强 Markdown 样式 ── */
.msg.ai .code-block-wrapper {
margin: 8px 0; border-radius: 6px; overflow: hidden;
border: 1px solid #333; background: #1a1a2e;
}
.msg.ai .code-header {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 10px; background: #222; border-bottom: 1px solid #333;
font-size: 11px;
}
.msg.ai .code-lang { color: #888; font-weight: 500; }
.msg.ai .copy-btn {
background: none; border: 1px solid #444; color: #aaa;
padding: 1px 8px; border-radius: 3px; cursor: pointer;
font-size: 10px; transition: all 0.15s;
}
.msg.ai .copy-btn:hover { background: #333; color: #fff; border-color: #666; }
.msg.ai .code-block-wrapper pre { margin: 0; padding: 8px; overflow-x: auto; border-radius: 0; }
.msg.ai .code-block-wrapper code { font-size: 12px; line-height: 1.4; }
.msg.ai table {
border-collapse: collapse; margin: 8px 0; width: 100%; font-size: 12px;
}
.msg.ai th, .msg.ai td {
border: 1px solid #333; padding: 5px 8px; text-align: left;
}
.msg.ai th { background: #1a1a2e; color: #4ade80; font-weight: 600; }
.msg.ai tr:nth-child(even) { background: #111; }
.msg.ai blockquote {
border-left: 3px solid #4ade80; padding: 3px 10px; margin: 6px 0;
color: #999; background: #111; border-radius: 0 3px 3px 0;
}
.msg.ai ul, .msg.ai ol { padding-left: 18px; margin: 6px 0; }
.msg.ai li { margin: 2px 0; }
.msg.ai h1, .msg.ai h2, .msg.ai h3, .msg.ai h4 {
color: #4ade80; margin: 12px 0 6px; font-weight: 600;
}
.msg.ai h1 { font-size: 18px; }
.msg.ai h2 { font-size: 16px; }
.msg.ai h3 { font-size: 14px; }
.msg.ai h4 { font-size: 13px; }
.msg.ai p { margin: 6px 0; line-height: 1.6; }
.msg.ai a { color: #4ade80; text-decoration: underline; }
.msg.ai hr { border: none; border-top: 1px solid #333; margin: 10px 0; }
.msg.ai img { max-width: 100%; border-radius: 6px; margin: 6px 0; }
.msg.ai strong { color: #4ade80; }
.msg .time { font-size: 10px; color: #555; margin-top: 2px; text-align: right; }
#input-area {
padding: 8px 16px; background: #111; border-top: 1px solid #222;
display: flex; gap: 8px; align-items: center; flex-shrink: 0;
}
#input {
flex: 1; background: #1a1a1a; border: 1px solid #333; border-radius: 20px;
padding: 8px 14px; color: #e0e0e0; font-size: 13px; outline: none;
transition: border-color 0.2s;
}
#input:focus { border-color: #4ade80; }
#input:disabled { opacity: 0.5; }
#send {
width: 34px; height: 34px; border-radius: 50%; border: none;
background: #4ade80; color: #0a0a0a; font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.2s; flex-shrink: 0;
}
#send:disabled { opacity: 0.4; cursor: not-allowed; }
#send:hover:not(:disabled) { opacity: 0.85; }
/* ── 附件上传 ── */
#upload-btn {
background: none; border: 1px solid #444; color: #888;
width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
font-size: 16px; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: all 0.15s; position: relative;
}
#upload-btn:hover { border-color: #4ade80; color: #4ade80; background: #1a3a2a22; }
#upload-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#upload-btn.uploading { pointer-events: none; opacity: 0.7; }
#upload-progress {
display: none; position: absolute; bottom: -3px; left: 2px; right: 2px;
height: 2px; background: #333; border-radius: 1px; overflow: hidden;
}
#upload-progress .bar {
height: 100%; background: #4ade80; width: 0%; border-radius: 1px;
transition: width 0.3s;
}
#upload-btn.uploading #upload-progress { display: block; }
#upload-name {
font-size: 11px; color: #888; max-width: 120px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex-shrink: 1; min-width: 0;
}
#typing { font-size: 11px; color: #666; padding: 2px 16px; flex-shrink: 0; min-height: 18px; }
.typing-dots::after { content: '...'; animation: dots 1.5s infinite; }
@keyframes dots { 0%,20% { content: '.'; } 40% { content: '..'; } 60%,100% { content: '...'; } }
/* Markdown */
.msg.ai table { border-collapse: collapse; margin: 6px 0; font-size: 12px; width: 100%; }
.msg.ai th, .msg.ai td { border: 1px solid #333; padding: 4px 8px; text-align: left; }
.msg.ai th { background: #2a2a3e; color: #4ade80; }
.msg.ai blockquote { border-left: 3px solid #4ade80; padding-left: 10px; margin: 6px 0; color: #999; }
/* 滚动条 */
#task-list::-webkit-scrollbar, #wiki-panel::-webkit-scrollbar, #messages::-webkit-scrollbar { width: 3px; }
#task-list::-webkit-scrollbar-thumb, #wiki-panel::-webkit-scrollbar-thumb, #messages::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
#task-list::-webkit-scrollbar-track, #wiki-panel::-webkit-scrollbar-track, #messages::-webkit-scrollbar-track { background: transparent; }
@media (max-width: 768px) {
#sidebar { width: 100%; min-width: 0; }
#app { flex-direction: column; }
#chat-area { height: 50vh; }
#sidebar { height: 50vh; }
}
/* ── 文档抽屉 ── */
#doc-drawer-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4); z-index: 100; animation: fadeIn .15s ease;
}
#doc-drawer-overlay.open { display: block; }
#doc-drawer {
position: fixed; top: 0; right: -50vw; width: 50vw; height: 100vh;
background: #f0f0f0; z-index: 101;
display: flex; flex-direction: column;
box-shadow: -4px 0 20px rgba(0,0,0,0.3);
transition: right .25s ease;
}
#doc-drawer.open { right: 0; }
#doc-drawer-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; background: #e6e6e6; border-bottom: 1px solid #ccc;
flex-shrink: 0;
}
#doc-drawer-title {
font-size: 14px; font-weight: 600; color: #1a1a2e; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; flex: 1; margin-right: 12px;
}
#doc-drawer-close {
background: none; border: 1px solid #bbb; color: #555; width: 28px; height: 28px;
border-radius: 4px; cursor: pointer; font-size: 14px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
#doc-drawer-close:hover { background: #ddd; }
#doc-drawer-body {
flex: 1; overflow-y: auto; background: #fff;
}
#doc-drawer-body iframe {
width: 100%; height: 100%; border: none; display: block;
}
#doc-drawer-loading {
display: flex; align-items: center; justify-content: center;
height: 100%; color: #888; font-size: 14px;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@media (max-width: 768px) {
#doc-drawer { width: 100vw; right: -100vw; }
}
/* ── 推理折叠面板 ── */
.reasoning-panel {
margin: 4px 0; border-radius: 6px; overflow: hidden;
border: 1px solid #333; background: #141422;
}
.reasoning-header {
display: flex; align-items: center; gap: 6px;
padding: 5px 10px; cursor: pointer; user-select: none;
font-size: 12px; color: #888; transition: background 0.15s;
}
.reasoning-header:hover { background: #1a1a2e; }
.reasoning-header .arrow {
transition: transform 0.2s; font-size: 9px;
}
.reasoning-header .arrow.open { transform: rotate(90deg); }
.reasoning-header .icon { font-size: 12px; }
.reasoning-body {
display: none; padding: 0 10px 8px;
font-size: 12px; color: #888; line-height: 1.5;
border-top: 1px solid #222;
}
.reasoning-body.open { display: block; }
.reasoning-body .thinking-text {
padding: 6px 0; white-space: pre-wrap; word-wrap: break-word;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11px; color: #777;
}
/* ── 打字动画 ── */
.typing-cursor::after {
content: ' ▉'; animation: blink 0.8s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* ── 工具调用卡片 ── */
.tool-call-card {
margin: 4px 0; border-radius: 6px; overflow: hidden;
border: 1px solid #333; background: #151525;
}
.tool-call-header {
display: flex; align-items: center; gap: 6px;
padding: 5px 10px; font-size: 12px; color: #aaa;
background: #1a1a2e; border-bottom: 1px solid #222;
}
.tool-call-header .tool-icon { font-size: 12px; }
.tool-call-header .tool-name {
font-weight: 500; color: #7c9bff; font-family: 'SF Mono', 'Consolas', monospace;
}
.tool-call-header .tool-status {
margin-left: auto; font-size: 10px;
display: flex; align-items: center; gap: 3px;
}
.tool-call-header .tool-status.running { color: #f59e0b; }
.tool-call-header .tool-status.done { color: #4ade80; }
.tool-call-header .tool-status.error { color: #ef4444; }
.tool-status-spinner {
display: inline-block; width: 10px; height: 10px;
border: 2px solid #555; border-top-color: #f59e0b;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.tool-call-body {
padding: 5px 10px; font-size: 11px; color: #777;
font-family: 'SF Mono', 'Consolas', monospace;
white-space: pre-wrap; word-wrap: break-word;
max-height: 150px; overflow-y: auto;
}
.tool-call-body .tool-args {
color: #888; margin-bottom: 2px;
}
.tool-call-body .tool-args-key { color: #7c9bff; }
.tool-call-body .tool-args-val { color: #ce9178; }
.tool-call-body .tool-result {
color: #aaa; border-top: 1px solid #222; padding-top: 4px; margin-top: 2px;
}
.tool-call-body .tool-result-preview {
color: #888; font-size: 10px;
}
.tool-call-card.collapsed .tool-call-body { display: none; }
/* ── 消息操作按钮(编辑/删除) ── */
.msg-actions {
position: absolute; top: 4px; right: 4px;
display: none; gap: 2px; z-index: 10;
}
.msg:hover .msg-actions { display: flex; }
.msg .msg-actions button {
background: #222; border: 1px solid #444; color: #888;
padding: 2px 8px; border-radius: 4px; cursor: pointer;
font-size: 11px; transition: all 0.15s;
}
.msg .msg-actions button:hover { background: #333; color: #fff; }
.msg .msg-actions .btn-edit:hover { border-color: #4ade80; color: #4ade80; }
.msg .msg-actions .btn-delete:hover { border-color: #ef4444; color: #ef4444; }
/* ── 编辑模式 ── */
.msg.editing .msg-content { display: none; }
.msg .edit-area { display: none; }
.msg.editing .edit-area { display: block; }
.msg .edit-area textarea {
width: 100%; background: #1a1a2e; border: 1px solid #4ade80;
color: #e0e0e0; border-radius: 8px; padding: 10px;
font-size: 14px; font-family: inherit; line-height: 1.6;
resize: vertical; min-height: 80px; outline: none;
}
.msg .edit-area .edit-buttons {
display: flex; gap: 8px; margin-top: 8px;
}
.msg .edit-area .edit-buttons button {
padding: 6px 16px; border-radius: 6px; border: none;
cursor: pointer; font-size: 13px; font-weight: 500;
}
.msg .edit-area .btn-save {
background: #4ade80; color: #0a0a0a;
}
.msg .edit-area .btn-cancel {
background: #333; color: #aaa;
}
.msg .edit-area .btn-save:hover { opacity: 0.85; }
.msg .edit-area .btn-cancel:hover { background: #444; }
/* ── 删除确认弹窗 ── */
.delete-confirm {
display: none; margin-top: 8px; padding: 8px 12px;
background: #2a1a1a; border: 1px solid #ef4444; border-radius: 8px;
font-size: 13px; color: #ef4444;
}
.delete-confirm .del-buttons { display: flex; gap: 8px; margin-top: 6px; }
.delete-confirm .del-buttons button {
padding: 4px 14px; border-radius: 6px; border: none;
cursor: pointer; font-size: 12px;
}
.delete-confirm .btn-confirm-del { background: #ef4444; color: #fff; }
.delete-confirm .btn-cancel-del { background: #333; color: #aaa; }
.delete-confirm .btn-confirm-del:hover { opacity: 0.85; }
.delete-confirm .btn-cancel-del:hover { background: #444; }
.msg { position: relative; }
/* ── 子代理卡片 ── */
.subagent-card {
margin: 4px 0; border-radius: 6px; overflow: hidden;
border: 1px solid #334; background: #0d0d1a;
}
.subagent-header {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; font-size: 11px; color: #889;
background: #15152a; border-bottom: 1px solid #222;
}
.subagent-header .sa-icon { font-size: 11px; }
.subagent-header .sa-name { color: #7c9bff; font-weight: 500; }
.subagent-header .sa-badge {
font-size: 9px; background: #1a1a3a; color: #7c9bff;
padding: 1px 5px; border-radius: 6px;
}
.subagent-body {
padding: 4px 10px; font-size: 11px; color: #777;
font-family: 'SF Mono', 'Consolas', monospace;
white-space: pre-wrap; word-wrap: break-word;
max-height: 100px; overflow-y: auto;
}
.subagent-body .sa-thinking { color: #f59e0b; }
.subagent-body .sa-text { color: #aaa; }
.subagent-body .sa-tool { color: #7c9bff; }
.subagent-footer {
padding: 2px 10px 4px; font-size: 10px; color: #555;
border-top: 1px solid #1a1a2e; display: flex; gap: 10px;
}
.subagent-footer .sa-stat { display: flex; align-items: center; gap: 2px; }
/* ── 搜索面板 ── */
#search-panel {
display: none; padding: 6px 16px;
background: #111; border-bottom: 1px solid #222;
flex-shrink: 0;
}
#search-panel.open { display: flex; gap: 6px; align-items: center; }
#search-input {
flex: 1; background: #1a1a1a; border: 1px solid #333;
border-radius: 16px; padding: 5px 12px; color: #e0e0e0;
font-size: 12px; outline: none;
}
#search-input:focus { border-color: #4ade80; }
#search-btn, #search-close {
padding: 4px 12px; border-radius: 4px; border: none;
cursor: pointer; font-size: 11px;
}
#search-btn { background: #4ade80; color: #0a0a0a; }
#search-close { background: #333; color: #aaa; }
#search-btn:hover { opacity: 0.85; }
#search-close:hover { background: #444; }
#search-count { font-size: 11px; color: #666; }
.search-highlight { background: #4ade8033; padding: 0 2px; border-radius: 2px; }
/* ── 导出按钮 ── */
#export-btn {
background: none; border: 1px solid #444; color: #888;
padding: 2px 8px; border-radius: 4px; cursor: pointer;
font-size: 11px; margin-left: auto;
}
#export-btn:hover { border-color: #4ade80; color: #4ade80; }
.move-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 10000;
display: flex; align-items: center; justify-content: center;
}
.move-modal {
background: #1e1e1e; border: 1px solid #444; border-radius: 12px;
padding: 24px; min-width: 320px; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.move-modal-title { font-size: 16px; color: #e0e0e0; margin-bottom: 16px; }
.move-task-select {
width: 100%; padding: 10px 12px; border: 1px solid #444;
border-radius: 8px; background: #2a2a2a; color: #e0e0e0;
font-size: 14px; margin-bottom: 20px;
}
.move-modal-actions { display: flex; gap: 12px; justify-content: flex-end; }
.move-modal-actions .btn-cancel {
padding: 8px 20px; border: 1px solid #555; border-radius: 8px;
background: transparent; color: #aaa; cursor: pointer;
}
.move-modal-actions .btn-confirm {
padding: 8px 20px; border: none; border-radius: 8px;
background: #4a9eff; color: #fff; cursor: pointer;
}
.move-modal-actions .btn-confirm:hover { background: #3a8eef; }
</style>
</head>
<body>
<div id="app">
<!-- ── 左侧栏 ── -->
<div id="sidebar">
<div id="sidebar-header">
<span>⚡</span>
<span>Hermes</span>
<span class="badge" id="taskCount">0</span>
<button id="add-task-btn" onclick="toggleNewTaskForm()" title="新建任务">+</button>
</div>
<!-- IDEA 风格折叠面板:任务列表 -->
<div class="side-panel active" id="panel-tasks">
<div class="panel-header" onclick="togglePanel('tasks')">
<span class="panel-icon">📋</span>
<span class="panel-title">任务列表</span>
<span class="panel-count" id="taskCountPanel">0</span>
<span class="panel-arrow">▼</span>
</div>
<div class="panel-body" id="panel-body-tasks">
<div id="new-task-form">
<div class="form-row">
<label>任务名称</label>
<input type="text" id="new-task-name" placeholder="输入任务名称" onkeydown="if(event.key==='Enter')createTask()">
</div>
<div class="form-row">
<label>所属分类</label>
<select id="new-task-folder"></select>
</div>
<div class="form-error" id="task-form-error">⚠️ 请填写任务名称</div>
<div class="form-actions">
<button class="btn-cancel" onclick="toggleNewTaskForm()">取消</button>
<button class="btn-create" onclick="createTask()">创建</button>
</div>
</div>
<div class="task-search-box">
<input type="text" id="taskSearch" placeholder="搜索task..." oninput="onTaskSearchInput()">
</div>
<div id="task-list"></div>
</div>
</div>
<!-- IDEA 风格折叠面板:知识沉淀 -->
<div class="side-panel" id="panel-wiki">
<div class="panel-header" onclick="togglePanel('wiki')">
<span class="panel-icon">📚</span>
<span class="panel-title">知识沉淀</span>
<span class="panel-count" id="wikiCountPanel">0</span>
<span class="panel-arrow">▼</span>
</div>
<div class="panel-body" id="panel-body-wiki">
<div id="wiki-panel"></div>
</div>
</div>
<!-- IDEA 风格折叠面板:相关附件 -->
<div class="side-panel" id="panel-attachments">
<div class="panel-header" onclick="togglePanel('attachments')">
<span class="panel-icon">📎</span>
<span class="panel-title">相关附件</span>
<span class="panel-count" id="attCountPanel">0</span>
<span class="panel-arrow">▼</span>
</div>
<div class="panel-body" id="panel-body-attachments">
<div id="attachment-list"><div class="att-empty">暂无附件</div></div>
</div>
</div>
<!-- IDEA 风格折叠面板:表单 -->
<div class="side-panel" id="panel-forms">
<div class="panel-header" onclick="togglePanel('forms')">
<span class="panel-icon">📝</span>
<span class="panel-title">表单</span>
<span class="panel-count" id="formCountPanel">0</span>
<span class="panel-arrow">▼</span>
</div>
<div class="panel-body" id="panel-body-forms">
<div id="form-list"><div class="form-empty">暂无表单</div></div>
</div>
</div>
<!-- IDEA 风格折叠面板:归属Skill -->
<div class="side-panel" id="panel-skills">
<div class="panel-header" onclick="togglePanel('skills')">
<span class="panel-icon">🎯</span>
<span class="panel-title">归属 Skill</span>
<span class="panel-count" id="skillCountPanel">0</span>
<span class="panel-arrow">▼</span>
</div>
<div class="panel-body" id="panel-body-skills">
<div id="skill-assign-list"><div class="skill-assign-empty">🏗️ 暂无归属 — 通过 task-org 配置</div></div>
</div>
</div>
</div>
<!-- 右侧聊天窗口 -->
<div id="chat-area">
<div id="chat-header">
<h1 id="chat-title">💬 Hermes Chat</h1>
<div class="subtitle">按任务隔离聊天</div>
<button id="search-toggle" onclick="toggleSearch()" style="background:none;border:1px solid #444;color:#888;padding:2px 8px;border-radius:4px;cursor:pointer;font-size:11px;">🔍</button>
<div id="status" class="offline">● 离线</div>
</div>
<!-- 搜索面板 -->
<div id="search-panel">
<input id="search-input" type="text" placeholder="搜索消息关键字..." onkeydown="if(event.key==='Enter') doSearch()">
<button id="search-btn" onclick="doSearch()">搜索</button>
<span id="search-count"></span>
<button id="search-close" onclick="closeSearch()">✕</button>
</div>
<div id="no-task-msg">← 从左侧选择一个任务开始对话</div>
<div id="messages" style="display:none;"></div>
<div id="typing"></div>
<div id="input-area" style="display:none;">
<button id="upload-btn" onclick="document.getElementById('file-input').click()" title="上传附件">
📎
<span id="upload-progress"><span class="bar" id="upload-progress-bar"></span></span>
</button>
<input type="file" id="file-input" style="display:none" onchange="handleFileSelect(this)">
<span id="upload-name"></span>
<textarea id="input" rows="1" placeholder="Enter 换行 · Shift+Enter 发送" autofocus disabled style="resize:none;line-height:1.5;max-height:120px;overflow-y:auto;"></textarea>
<button id="send" disabled>↵</button>
</div>
</div>
</div>
<!-- 文档阅读抽屉 -->
<div id="doc-drawer-overlay" onclick="closeDocDrawer()"></div>
<div id="doc-drawer">
<div id="doc-drawer-header">
<span id="doc-drawer-title">📄 文档</span>
<button id="doc-drawer-close" onclick="closeDocDrawer()">✕</button>
</div>
<div id="doc-drawer-body">
<div id="doc-drawer-loading">加载中...</div>
<iframe id="doc-drawer-iframe" style="display:none;"></iframe>
</div>
</div>
<script>
// ═══════════════════════════════════════════
// Hermes Chat v3 — 任务驱动前端
// ═══════════════════════════════════════════
const WS_PROTO = location.protocol === 'https:' ? 'wss:' : 'ws:';
const WS_URL = `${WS_PROTO}//${location.host}/ws/`;
const API_BASE = `/api`;
let ws = null;
let msgId = 0;
let currentTaskId = null;
let currentTaskName = '';
let tasks = [];
let allDirs = []; // task_dirs 目录树
let streamingTaskId = null; // 当前正在流式回复的 task_id,用于任务隔离
let openDirIds = new Set(); // 目录展开状态持久化
let pendingUpdates = new Set(); // 有新消息但被隔离的任务ID,切回时自动刷新
const el = id => document.getElementById(id);
const taskList = el('task-list');
const wikiPanel = el('wiki-panel');
const messages = el('messages');
const noTaskMsg = el('no-task-msg');
const input = el('input');
const sendBtn = el('send');
const status = el('status');
const typing = el('typing');
const chatTitle = el('chat-title');
const taskCount = el('task-count');
// ═══════════════════════════════════════════
// API 调用
// ═══════════════════════════════════════════
async function apiGet(path) {
try {
const resp = await fetch(`${API_BASE}${path}`, { cache: 'no-store' });
return await resp.json();
} catch(e) {
console.error('API error:', e);
return null;
}
}
async function loadTasks() {
// 加载 task_dirs 目录树
const [dirData, taskData] = await Promise.all([
apiGet('/task-dirs'),
apiGet('/tasks-with-folder')
]);
if (dirData && dirData.dirs) {
allDirs = dirData.dirs;
}
if (taskData && taskData.tasks) {
tasks = taskData.tasks.sort((a,b) => a.name.localeCompare(b.name));
el('taskCount').textContent = tasks.length + ' 任务';
el('taskCountPanel').textContent = tasks.length;
}
renderTaskList();
// 自动恢复上次的task
const lastTaskId = localStorage.getItem('hermes_last_task_id');
if (lastTaskId && !currentTaskId && tasks.some(t => t.id === lastTaskId)) {
selectTask(lastTaskId);
}
}
// ═══════════════════════════════════════════
// 浏览器端聊天缓存(localStorage)
// ═══════════════════════════════════════════
const CACHE_KEY = 'webchat_cache_v2';
function cacheGet(taskId) {
try {
const all = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
return all[taskId] || null;
} catch(e) { return null; }
}
function cacheSetAll(taskId, messages) {
try {
const all = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
all[taskId] = messages;
localStorage.setItem(CACHE_KEY, JSON.stringify(all));
} catch(e) {}
}
function cacheAppend(taskId, msg) {
try {
const all = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
if (!all[taskId]) all[taskId] = [];
const exists = all[taskId].some(m => m.id === msg.id || (m._temp && m._temp === msg._temp));
if (!exists) all[taskId].push(msg);
localStorage.setItem(CACHE_KEY, JSON.stringify(all));
} catch(e) {}
}
function cacheUpdateLast(taskId, content, id) {
try {
const all = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
if (all[taskId] && all[taskId].length > 0) {
const last = all[taskId][all[taskId].length - 1];
if (last.role === 'assistant') {
last.content = content;
if (id) last.id = id;
}
}
localStorage.setItem(CACHE_KEY, JSON.stringify(all));
} catch(e) {}
}
function cacheClear(taskId) {
try {
const all = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}');
delete all[taskId];
localStorage.setItem(CACHE_KEY, JSON.stringify(all));
} catch(e) {}
}
// ═══════════════════════════════════════════
// 新建任务
// ═══════════════════════════════════════════
function toggleNewTaskForm() {
const form = el('new-task-form');
const isOpen = form.classList.contains('open');
form.classList.toggle('open');
if (!isOpen) {
// 填充目录下拉
const sel = document.getElementById('new-task-folder');
sel.innerHTML = '<option value="">— 未分类 —</option>';
if (allDirs && allDirs.length) {
// 展平显示目录层级(跳过根节点自身)
function flatDirs(parentId, depth) {
const children = allDirs.filter(d => d.parent_id === parentId)
.sort((a,b) => (a.sort_order||0) - (b.sort_order||0));
children.forEach(d => {
const indent = ' '.repeat(depth);
sel.innerHTML += `<option value="${d.id}">${indent}${d.name}</option>`;
flatDirs(d.id, depth + 1);
});
}
const rootDir = allDirs.find(d => d.parent_id === null);
if (rootDir) {
flatDirs(rootDir.id, 0); // 从根节点的子目录开始
} else {
flatDirs(null, 0);
}
}
el('task-form-error').classList.remove('show');
el('task-form-error').textContent = '';
el('new-task-name').value = '';
el('new-task-name').focus();
}
}
async function createTask() {
const name = el('new-task-name').value.trim();
const dirId = document.getElementById('new-task-folder').value;
const errorEl = el('task-form-error');
if (!name) {
errorEl.textContent = '请输入标题';
errorEl.classList.add('show');
el('new-task-title').focus();
return;
}
errorEl.classList.remove('show');
try {
const resp = await fetch(`${API_BASE}/tasks/create`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, dir_id: dirId || null}),
});
const data = await resp.json();
if (data.ok) {
// 关闭表单 + 刷新树
el('new-task-form').classList.remove('open');
await loadTasks();
} else {
errorEl.textContent = data.error || '创建失败';
errorEl.classList.add('show');
}
} catch(e) {
errorEl.textContent = '网络错误: ' + e.message;
errorEl.classList.add('show');
}
}
// ── IDEA 面板折叠切换 ──
let activePanel = 'tasks'; // 当前展开的面板
function togglePanel(name) {
const panels = ['tasks', 'wiki', 'attachments', 'forms', 'skills'];
panels.forEach(p => {
const el = document.getElementById('panel-' + p);
if (p === name) {
el.classList.add('active');
el.classList.remove('collapsed');
activePanel = p;
} else {
el.classList.remove('active');
el.classList.add('collapsed');
}
});
// 切换到附件面板时自动加载
if (name === 'attachments' && currentTaskId) {
renderAttachments(currentTaskId);
}
}
// ── 附件列表 ──
async function renderAttachments(taskId) {
const container = document.getElementById('attachment-list');
try {
const data = await apiGet(`/chat/attachments?task_id=${taskId}&limit=50`);
if (!data || !data.attachments || data.attachments.length === 0) {
container.innerHTML = '<div class="att-empty">暂无附件</div>';
el('attCountPanel').textContent = '0';
return;
}
el('attCountPanel').textContent = data.attachments.length;
const imgTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const codeTypes = ['text/plain', 'text/markdown', 'text/html', 'application/json', 'text/css', 'text/javascript'];
let html = '';
data.attachments.forEach(a => {
const isImg = imgTypes.includes(a.mime_type);
const isCode = codeTypes.includes(a.mime_type);
const icon = isImg ? '🖼️' : isCode ? '📄' : '📎';
const sizeStr = a.filesize > 1024*1024
? (a.filesize/1024/1024).toFixed(1) + 'MB'
: a.filesize > 1024
? (a.filesize/1024).toFixed(0) + 'KB'
: a.filesize + 'B';
html += `<div class="att-item" onclick="previewAttachment('${a.id}')" title="${a.display_name || a.filename}">
<span class="att-icon">${icon}</span>
<span class="att-info">
<span class="att-name">${a.display_name || a.filename}</span>
<span class="att-meta">${a.created_at}</span>
</span>
<span class="att-size">${sizeStr}</span>
</div>`;
});
container.innerHTML = html;
} catch(e) {
container.innerHTML = '<div class="att-empty">加载失败</div>';
}
}
// ── 表单列表 ──
async function renderFormList() {
const container = document.getElementById('form-list');
const countEl = document.getElementById('formCountPanel');
if (!currentTaskId) {
container.innerHTML = '<div class="form-empty">← 选择一个Task查看表单</div>';
if (countEl) countEl.textContent = '0';
return;
}
try {
const r = await fetch(`${API_BASE}/forms?task_id=${currentTaskId}`);
const data = await r.json();
const forms = data.forms || [];
if (countEl) countEl.textContent = forms.length;
if (!forms.length) {
container.innerHTML = '<div class="form-empty">暂无表单</div>';
return;
}
let html = '';
forms.forEach(f => {
html += `<div class="form-item" onclick="openFormOverlay('${f.id}')">
<span class="fname">📝 ${escapeHtml(f.title)}</span>
<span style="font-size:10px;color:#555;">${(f.schema_json.fields||[]).length}项</span>
</div>`;
});
container.innerHTML = html;
} catch(e) {
container.innerHTML = '<div class="form-empty">加载失败</div>';
}
}
// ── 表单内嵌填写(覆写层)──
function openFormOverlay(formId) {
const overlay = document.getElementById('form-overlay') || createFormOverlay();
overlay.classList.add('show');
overlay.querySelector('.form-overlay-saved').style.display = 'none';
const body = overlay.querySelector('.form-overlay-body');
body.innerHTML = '<div style="text-align:center;padding:20px;color:#555;">加载中...</div>';
// 并行加载表单定义 + 已有值
Promise.all([
fetch(`${API_BASE}/forms/render/${formId}`).then(r => r.json()),
fetch(`${API_BASE}/forms/${formId}/values`).then(r => r.status === 200 ? r.json() : {values_json:{}})
]).then(([formData, valData]) => {
if (!formData.form_id) {
body.innerHTML = '<div style="text-align:center;padding:20px;color:#555;">表单不存在</div>';
return;
}
const schema = formData.schema || {};
const fields = schema.fields || [];
const values = valData.values_json || {};
overlay.querySelector('.form-overlay-title').textContent = formData.title;
let html = '';
fields.forEach(f => {
const required = f.required ? '<span class="required">*</span>' : '';
html += `<div class="form-field"><div class="form-field-label">${escapeHtml(f.label)}${required}</div>`;
if (f.type === 'text') {
const v = escapeHtml(values[f.name] || '');
html += `<div class="form-field-text"><input type="text" id="form-field-${f.name}" value="${v}"></div>`;
} else if (f.type === 'radio') {
html += '<div class="form-field-radio">';
(f.options || []).forEach(opt => {
const checked = values[f.name] === opt.value ? 'checked' : '';
html += `<label><input type="radio" name="form-${f.name}" value="${escapeHtml(opt.value)}" ${checked}><span>${escapeHtml(opt.label)}</span></label>`;
});
html += '</div>';
}
html += '</div>';
});
body.innerHTML = html;
// 保存事件
overlay.querySelector('.form-overlay-btn').onclick = () => saveFormOverlay(formId, fields, overlay);
}).catch(() => {
body.innerHTML = '<div style="text-align:center;padding:20px;color:#555;">加载失败</div>';
});
}
function createFormOverlay() {
const div = document.createElement('div');
div.className = 'form-overlay';
div.id = 'form-overlay';
div.onclick = function(e) { if (e.target === this) this.classList.remove('show'); };
div.innerHTML = `
<div class="form-overlay-card" onclick="event.stopPropagation()">
<div class="form-overlay-header">
<div class="form-overlay-title"></div>
<button class="form-overlay-close" onclick="document.getElementById('form-overlay').classList.remove('show')">✕</button>
</div>
<div class="form-overlay-body"></div>
<div class="form-overlay-saved">✅ 已保存</div>
<button class="form-overlay-btn">保存</button>
</div>`;
document.body.appendChild(div);
return div;
}
async function saveFormOverlay(formId, fields, overlay) {
const values = {};
fields.forEach(f => {
if (f.type === 'text') {
const el = document.getElementById(`form-field-${f.name}`);
if (el) values[f.name] = el.value;
} else if (f.type === 'radio') {
const el = document.querySelector(`input[name="form-${f.name}"]:checked`);
if (el) values[f.name] = el.value;
}
});
try {
const r = await fetch(`${API_BASE}/forms/${formId}/values`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({values})
});
const data = await r.json();
if (data.ok) {
const hint = overlay.querySelector('.form-overlay-saved');
hint.style.display = 'block';
setTimeout(() => { hint.style.display = 'none'; }, 2000);
}
} catch(e) { alert('保存失败'); }
}
// ── 归属 Skill ──
async function renderSkillAssign() {
const container = document.getElementById('skill-assign-list');
try {
const r = await fetch(`${API_BASE}/skills`);
const skills = await r.json();
if (!skills || !skills.length) {
container.innerHTML = '<div class="skill-assign-empty">🏗️ 暂无归属 — 通过 task-org 配置</div>';
return;
}
let html = '';
skills.forEach(s => {
const category = s.category || '未分类';
html += `<div class="skill-assign-item" onclick="openSkillDrawer('${escapeHtml(s.name)}', '${escapeHtml(s.description||'')}', '${escapeHtml(category)}')">
<span class="sname">🧠 ${escapeHtml(s.name)}</span>
<span class="stask-count">${escapeHtml(category)}</span>
</div>`;
});
container.innerHTML = html;
} catch(e) {
container.innerHTML = '<div class="skill-assign-empty">加载失败</div>';
}
}
function openSkillDrawer(name, desc, category) {
const overlay = document.getElementById('skill-drawer') || createSkillDrawer();
overlay.classList.add('show');
overlay.querySelector('.skill-drawer-name').textContent = name;
overlay.querySelector('.skill-drawer-cat').textContent = category;
overlay.querySelector('.skill-drawer-desc').textContent = desc || '暂无描述';
}
function createSkillDrawer() {
const div = document.createElement('div');
div.className = 'skill-drawer-overlay';
div.id = 'skill-drawer';
div.onclick = function(e) { if (e.target === this) this.classList.remove('show'); };
div.innerHTML = `
<div class="skill-drawer-card" onclick="event.stopPropagation()">
<div class="skill-drawer-header">
<button class="form-overlay-close" onclick="document.getElementById('skill-drawer').classList.remove('show')">✕</button>
<div class="skill-drawer-name"></div>
<div class="skill-drawer-cat"></div>
</div>
<div class="skill-drawer-desc"></div>
<div style="font-size:11px;color:#555;text-align:center;padding-top:8px;">通过 task-org 配置归属</div>
</div>`;
document.body.appendChild(div);
return div;
}
// ── 附件预览 ──
function previewAttachment(id) {
// 获取详情
fetch(`${API_BASE}/chat/attachment/${id}/download`)
.then(r => r.json())
.then(data => {
if (!data.url) { alert('附件不存在'); return; }
const overlay = document.getElementById('att-preview-overlay') || createPreviewOverlay();
document.getElementById('att-preview-name').textContent = data.filename || '附件';
document.getElementById('att-preview-size').textContent = data.filesize ?
(data.filesize > 1024*1024 ? (data.filesize/1024/1024).toFixed(1)+'MB' :
data.filesize > 1024 ? (data.filesize/1024).toFixed(0)+'KB' : data.filesize+'B') : '';
const body = document.getElementById('att-preview-body');
const ext = (data.filename || '').split('.').pop().toLowerCase();
const imgExts = ['png','jpg','jpeg','gif','webp','svg','bmp','ico'];
const videoExts = ['mp4','webm','ogg','mov'];
const textExts = ['txt','md','html','json','css','js','py','sh','go','rs','java','c','cpp','h','yaml','yml','toml','xml','cfg','conf','ini'];
if (imgExts.includes(ext)) {
body.innerHTML = `<img src="${data.url}" alt="${data.filename}" onerror="this.outerHTML='<div class=\\'att-unsupported\\'>图片加载失败</div>'">`;
} else if (videoExts.includes(ext)) {
body.innerHTML = `<video controls src="${data.url}"></video>`;
} else if (textExts.includes(ext)) {
fetch(data.url).then(r => r.text()).then(text => {
body.innerHTML = `<div class="att-text-preview">${escapeHtml(text)}</div>`;
}).catch(() => {
body.innerHTML = `<div class="att-text-preview">(无法加载文本预览)</div>`;
});
} else if (ext === 'pdf') {
body.innerHTML = `<iframe src="${data.url}"></iframe>`;
} else {
body.innerHTML = `<div class="att-unsupported">⚠️ 该类型无法预览<br><a href="${data.url}" target="_blank" style="color:#4ade80;">下载文件</a></div>`;
}
overlay.classList.add('show');
})
.catch(e => alert('加载失败: ' + e.message));
}
function createPreviewOverlay() {
const div = document.createElement('div');
div.className = 'att-preview-overlay';
div.id = 'att-preview-overlay';
div.onclick = function(e) { if (e.target === this) this.classList.remove('show'); };
div.innerHTML = `
<div class="att-preview-modal" onclick="event.stopPropagation()">
<div class="att-preview-header">
<span class="att-preview-name" id="att-preview-name">附件</span>
<span style="font-size:11px;color:#666;" id="att-preview-size"></span>
<button class="att-preview-close" onclick="document.getElementById('att-preview-overlay').classList.remove('show')">✕</button>
</div>
<div class="att-preview-body" id="att-preview-body">
<div class="att-unsupported">加载中...</div>
</div>
</div>
`;
document.body.appendChild(div);
return div;
}
// ── Wiki 知识树 ──
async function loadWiki(taskId, folderId) {
if (folderId) {
const data = await apiGet(`/chat/tree?folder_id=${folderId}`);
if (data && data.tree) {
renderWikiTree(data.tree);
return;
}
}
if (taskId) {
const data = await apiGet(`/chat/tree?task_id=${taskId}`);
if (data && data.tree) {
renderWikiTree(data.tree);
return;
}
}
wikiPanel.innerHTML = '<div class="wiki-doc-empty">暂无知识沉淀</div>';
}
// 全局锁:目前正在加载历史的 task id
let loadingTaskId = null;
async function loadHistory(taskId, offset, limit, append) {
// 如果正在加载其他task的历史,等500ms重试
if (loadingTaskId && loadingTaskId !== taskId) {
await new Promise(r => setTimeout(r, 500));
if (loadingTaskId && loadingTaskId !== taskId) return;
}
loadingTaskId = taskId;
try {
const data = await apiGet(`/chat/history?task_id=${taskId}&offset=${offset}&limit=${limit}`);
if (data && data.messages) {
hasMoreHistory = data.has_more;
// 保存到浏览器缓存(仅首次加载时覆盖,丢弃 reasoning 等大字段)
if (!append && offset === 0) {
const clean = data.messages.map(m => ({
id: m.id, role: m.role, content: m.content,
timestamp: m.timestamp || m.created_at,
}));
cacheSetAll(taskId, clean);
} else if (append && data.messages.length > 0) {
// 追加历史时也补充到缓存
const cached = cacheGet(taskId);
if (cached) {
// 把新加载的旧消息插入到缓存头部
const merged = [...data.messages.filter(m => !cached.some(c => c.id === m.id)), ...cached];
cacheSetAll(taskId, merged);
}
}
renderMessages(data.messages, append);
}
} finally {
loadingTaskId = null;
}
}
// 延迟加载历史 — 等 loadingTaskId 释放后重试
function delayedLoadHistory(taskId, offset, limit, delay) {
setTimeout(async () => {
for (let retry = 0; retry < 5; retry++) {
if (!loadingTaskId || loadingTaskId === taskId) {
await loadHistory(taskId, offset, limit);
return;
}
await new Promise(r => setTimeout(r, 300));
}
await loadHistory(taskId, offset, limit);
}, delay);
}
async function loadMoreHistory() {
if (isLoadingMore || !hasMoreHistory || loadingTaskId || !currentTaskId) return;
isLoadingMore = true;
historyOffset += historyLimit;
await loadHistory(currentTaskId, historyOffset, historyLimit, true);
isLoadingMore = false;
}
// ═══════════════════════════════════════════
// 渲染
// ═══════════════════════════════════════════
function renderTaskList() {
if (!tasks.length && !allDirs.length) {
taskList.innerHTML = '<div class="task-empty">暂无任务</div>';
return;
}
const q = getTaskSearchQuery();
// 使用 task_dirs 目录树
if (allDirs && allDirs.length > 0) {
const root = allDirs.find(d => d.parent_id === null);
const orphans = tasks.filter(t => !t.dir_id).filter(t => taskMatchesSearch(t));
let html = '';
if (root) {
html += renderDirNodes(allDirs, root.id, 0);
}
// 未分类任务(无 dir_id)
if (orphans.length > 0) {
html += '<div class="task-folder">';
html += `<div class="task-folder-header" onclick="toggleTaskFolder(this)">
<span class="arrow">▶</span>
<span class="fname">📋 未分类</span>
<span style="margin-left:auto;font-size:10px;color:#555;">${orphans.length}</span>
</div>`;
html += '<div class="task-folder-children">';
orphans.forEach(t => {
const dotCls = t.status === 'running' ? 'running' : t.status === 'active' ? 'active' : t.status === 'created' ? 'created' : 'completed';
const active = t.id === currentTaskId ? ' active' : '';
html += `<div class="task-folder-item${active}" onclick="selectTask('${t.id}')">
<span class="dot ${dotCls}"></span>
<span class="tname">${escapeHtml(t.name)}</span>
</div>`;
});
html += '</div></div>';
}
taskList.innerHTML = html;
} else {
// 降级:平铺列表
const filtered = q ? tasks.filter(t => taskMatchesSearch(t)) : tasks;
taskList.innerHTML = filtered.map(t => {
const active = t.id === currentTaskId ? 'active' : '';
const statusLabel = {created:'未开始', running:'进行中', paused:'已暂停', completed:'已完成', cancelled:'已取消'}[t.status] || t.status;
const updatedStr = t.updated_at ? t.updated_at.slice(0, 16).replace('T', ' ') : '';
return `
<div class="task-item ${active}" data-task-id="${t.id}" onclick="selectTask('${t.id}')">
<div class="task-status ${t.status}"></div>
<div class="task-info">
<div class="task-name">${escapeHtml(t.name)}</div>
<div class="task-meta">${t.msg_count || 0} 条消息 · ${updatedStr}</div>
</div>
<div class="task-status-text">${statusLabel}</div>
</div>
`;
}).join('');
// 无结果提示
if (!filtered.length) {
taskList.innerHTML = '<div class="task-empty">无匹配任务</div>';
}
}
}
function renderDirNodes(dirs, parentId, depth) {
const children = dirs.filter(d => d.parent_id === parentId)
.sort((a,b) => (a.sort_order||0) - (b.sort_order||0));
if (!children.length) return '';
const q = getTaskSearchQuery();
let html = '';
children.forEach(d => {
const hasChildren = dirs.some(x => x.parent_id === d.id);
const tasksHere = tasks.filter(t => t.dir_id === d.id);
const subHtml = hasChildren ? renderDirNodes(dirs, d.id, depth + 1) : '';
const matchingTasks = tasksHere.filter(t => taskMatchesSearch(t));
// 搜索过滤:目录名匹配 or 有匹配的task or 有匹配的子目录
const dirMatch = !q || (d.name || '').toLowerCase().includes(q);
const taskMatch = !q || matchingTasks.length > 0;
const subMatch = !q || subHtml.trim();
if (q && !dirMatch && !taskMatch && !subMatch) return;
const isOpen = openDirIds.has(d.id) || q ? 'open' : '';
html += `<div class="task-folder ${isOpen}" data-dir-id="${d.id}">`;
html += `<div class="task-folder-header" onclick="toggleTaskFolder(this)">
<span class="arrow">${isOpen ? '▼' : '▶'}</span>
<span class="fname">📁 ${escapeHtml(d.name)}</span>
<span style="margin-left:auto;font-size:10px;color:#555;">${matchingTasks.length}</span>
</div>`;
html += '<div class="task-folder-children">';
// 子目录
html += subHtml;
// 本目录下的 task(已过滤)
matchingTasks.forEach(t => {
const dotCls = t.status === 'running' ? 'running' : t.status === 'active' ? 'active' : t.status === 'created' ? 'created' : 'completed';
const active = t.id === currentTaskId ? ' active' : '';
html += `<div class="task-folder-item${active}" onclick="selectTask('${t.id}')">
<span class="dot ${dotCls}"></span>
<span class="tname">${escapeHtml(t.name)}</span>
</div>`;
});
html += '</div></div>';
});
return html;
}
function toggleTaskFolder(headerEl) {
const folder = headerEl.closest('.task-folder');
const dirId = folder ? folder.dataset.dirId : null;
folder.classList.toggle('open');
const nowOpen = folder.classList.contains('open');
headerEl.querySelector('.arrow').textContent = nowOpen ? '▼' : '▶';
if (dirId) {
if (nowOpen) openDirIds.add(dirId);
else openDirIds.delete(dirId);
}
}
// ── 任务搜索 ──
function onTaskSearchInput() {
renderTaskList();
}
function getTaskSearchQuery() {
const el = document.getElementById('taskSearch');
return el ? (el.value || '').toLowerCase() : '';
}
function taskMatchesSearch(task) {
const q = getTaskSearchQuery();
if (!q) return true;
return (task.name || '').toLowerCase().includes(q);
}
function renderWikiTree(tree) {
// 更新计数
const docs = [];
function flatten(nodes) {
if (!nodes || !nodes.length) return;
nodes.forEach(n => {
if (n.type === 'doc') docs.push(n);
if (n.children) flatten(n.children);
});
}
flatten(tree);
el('wikiCountPanel').textContent = docs.length;
if (!tree || tree.length === 0) {
wikiPanel.innerHTML = '<div class="wiki-doc-empty">暂无知识沉淀</div>';
return;
}
wikiPanel.innerHTML = docs.map(n =>
`<div class="wiki-doc" onclick="openDoc('${n.id}')">${escapeHtml(n.name)}</div>`
).join('');
}
function renderMessages(msgs, append = false) {
if (!append) {
messages.innerHTML = '';
historyOffset = 0;
hasMoreHistory = true;
}
msgs.forEach(m => {
const div = document.createElement('div');
div.className = `msg ${m.role === 'assistant' ? 'ai' : m.role}`;
div.dataset.msgId = m.id;
div.dataset.role = m.role;
// 内容区
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
contentDiv.innerHTML = m.role === 'assistant' ? renderMarkdown(m.content) : escapeHtml(m.content);
div.appendChild(contentDiv);
// 编辑区(仅助理消息可编辑)
if (m.role === 'assistant' || m.role === 'user') {
const editArea = document.createElement('div');
editArea.className = 'edit-area';
editArea.innerHTML = `
<textarea>${escapeHtml(m.content)}</textarea>
<div class="edit-buttons">
<button class="btn-save" onclick="saveEdit('${m.id}', this)">💾 保存</button>
<button class="btn-cancel" onclick="cancelEdit('${m.id}')">取消</button>
</div>`;
div.appendChild(editArea);
}
// 删除确认区
const delConfirm = document.createElement('div');
delConfirm.className = 'delete-confirm';
delConfirm.innerHTML = `
<span>确定删除此消息?</span>
<div class="del-buttons">
<button class="btn-confirm-del" onclick="confirmDelete('${m.id}')">删除</button>
<button class="btn-cancel-del" onclick="cancelDelete('${m.id}')">取消</button>
</div>`;
div.appendChild(delConfirm);
// 操作按钮
const actions = document.createElement('div');
actions.className = 'msg-actions';
actions.innerHTML = `
<button class="btn-edit" onclick="startEdit('${m.id}')">✏️</button>
<button class="btn-move" onclick="showMoveMessage('${m.id}')">⇄</button>
<button class="btn-delete" onclick="showDelete('${m.id}')">🗑️</button>`;
div.appendChild(actions);
// 时间
const time = document.createElement('div');
time.className = 'time';
time.textContent = m.created_at || new Date().toLocaleTimeString();
div.appendChild(time);
if (append) {
// 追加到顶部(历史记录加载)
messages.insertBefore(div, messages.firstChild);
} else {
messages.appendChild(div);
}
});
// 保持滚动位置
if (!append) {
messages.scrollTop = messages.scrollHeight;
}
}
// ── 消息编辑 ──
function startEdit(msgId) {
const msg = messages.querySelector(`[data-msg-id="${msgId}"]`);
if (!msg) return;
msg.classList.add('editing');
const textarea = msg.querySelector('.edit-area textarea');
if (textarea) {
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}
}
function cancelEdit(msgId) {
const msg = messages.querySelector(`[data-msg-id="${msgId}"]`);
if (!msg) return;
msg.classList.remove('editing');
}
async function saveEdit(msgId, btn) {
const msg = messages.querySelector(`[data-msg-id="${msgId}"]`);
if (!msg) return;
const textarea = msg.querySelector('.edit-area textarea');
const newContent = textarea ? textarea.value.trim() : '';
if (!newContent) return;
btn.textContent = '保存中…';
btn.disabled = true;
try {
const resp = await fetch(`http://${location.host}/api/chat/message/edit`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: parseInt(msgId), content: newContent}),
});
const data = await resp.json();
if (data.success) {
// 更新显示
const contentDiv = msg.querySelector('.msg-content');
if (contentDiv) {
const role = msg.dataset.role;
contentDiv.innerHTML = role === 'assistant' ? renderMarkdown(newContent) : escapeHtml(newContent);
}
msg.classList.remove('editing');
} else {
alert('编辑失败:' + (data.error || '未知错误'));
}
} catch(e) {
alert('网络错误:' + e.message);
}
btn.textContent = '💾 保存';
btn.disabled = false;
}
// ── 消息删除 ──
function showDelete(msgId) {
const msg = messages.querySelector(`[data-msg-id="${msgId}"]`);
if (!msg) return;
const confirm = msg.querySelector('.delete-confirm');
if (confirm) confirm.style.display = 'block';
}
function cancelDelete(msgId) {
const msg = messages.querySelector(`[data-msg-id="${msgId}"]`);
if (!msg) return;
const confirm = msg.querySelector('.delete-confirm');
if (confirm) confirm.style.display = 'none';
}
async function confirmDelete(msgId) {
try {
const resp = await fetch(`http://${location.host}/api/chat/message/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: parseInt(msgId)}),
});
const data = await resp.json();
if (data.success) {
const msg = messages.querySelector(`[data-msg-id="${msgId}"]`);
if (msg) {
const contentDiv = msg.querySelector('.msg-content');
if (contentDiv) contentDiv.innerHTML = '<em style="color:#555">[消息已删除]</em>';
const confirm = msg.querySelector('.delete-confirm');
if (confirm) confirm.style.display = 'none';
}
}
} catch(e) {
alert('删除失败:' + e.message);
}
}
// ── 搜索 ──
let searchActive = false;
function toggleSearch() {
const panel = el('search-panel');
panel.classList.toggle('open');
if (panel.classList.contains('open')) {
el('search-input').focus();
}
}
function closeSearch() {
el('search-panel').classList.remove('open');
el('search-count').textContent = '';
searchActive = false;
messages.querySelectorAll('.search-highlight').forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
}
async function doSearch() {
if (!currentTaskId) return;
const keyword = el('search-input').value.trim();
if (!keyword) return;
searchActive = true;
el('search-count').textContent = '搜索中...';
try {
const resp = await fetch(`http://${location.host}/api/chat/search?task_id=${currentTaskId}&q=${encodeURIComponent(keyword)}`);
const data = await resp.json();
const msgs = data.messages || [];
el('search-count').textContent = `找到 ${msgs.length} 条`;
if (msgs.length > 0) {
selectTask(currentTaskId);
setTimeout(() => {
renderMessages(msgs);
highlightKeyword(keyword);
}, 100);
}
} catch(e) {
el('search-count').textContent = '搜索失败';
}
}
function highlightKeyword(keyword) {
if (!keyword) return;
const regex = new RegExp(`(${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
messages.querySelectorAll('.msg-content').forEach(el => {
el.innerHTML = el.innerHTML.replace(regex, '<span class="search-highlight">$1</span>');
});
}
// ═══════════════════════════════════════════
// 交互
function isForCurrentTask(data) {
// 检查消息是否属于当前选中的任务,用于任务隔离
if (data.task_id && data.task_id !== currentTaskId) return false;
return true;
}
async function selectTask(taskId) {
if (currentTaskId === taskId) return;
currentTaskId = taskId;
localStorage.setItem('hermes_last_task_id', taskId);
currentTaskName = '';
const task = tasks.find(t => t.id === taskId);
if (task) currentTaskName = task.name;
renderTaskList();
chatTitle.textContent = `💬 ${escapeHtml(currentTaskName) || 'Hermes Chat'}`;
noTaskMsg.style.display = 'none';
messages.style.display = 'flex';
messages.innerHTML = '';
// 切换任务时清空流式残留
if (streamTimer) {
clearTimeout(streamTimer);
streamTimer = null;
}
streamBuffer = '';
streamingTaskId = null;
// 重置分页
historyOffset = 0;
hasMoreHistory = true;
isLoadingMore = false;
input.disabled = false;
sendBtn.disabled = false;
el('input-area').style.display = 'flex';
input.focus();
loadWiki(taskId);
// 加载附件
renderAttachments(taskId);
// 加载表单
renderFormList();
// 从浏览器缓存优先渲染(无需等待接口)
const cached = cacheGet(taskId);
if (cached && cached.length > 0) {
hasMoreHistory = true;
renderMessages(cached, false);
}
// 后台同步服务端最新消息(不阻塞切换)
setTimeout(async () => {
try {
const data = await apiGet(`/chat/history?task_id=${taskId}&offset=0&limit=${historyLimit}`);
if (data && data.messages && data.messages.length > 0) {
// 比较缓存长度和服务端长度,有新消息才渲染
const cachedNow = cacheGet(taskId);
if (!cachedNow || data.messages.length > cachedNow.length) {
const clean = data.messages.map(m => ({
id: m.id, role: m.role, content: m.content,
timestamp: m.timestamp || m.created_at,
}));
cacheSetAll(taskId, clean);
// 如果消息数有变化,重绘(避免覆盖用户正在查看的缓存渲染)
if (!cachedNow || data.messages.length !== cachedNow.length) {
renderMessages(clean, false);
}
}
hasMoreHistory = data.has_more;
}
} catch(e) {
console.warn('[SYNC] background sync failed:', e);
}
}, 300);
const currentTask = taskId;
setTimeout(async () => {
if (currentTaskId === currentTask && messages.children.length === 0) {
console.warn('[DEFENSIVE] history empty after selectTask, retrying...');
await loadHistory(currentTask, 0, historyLimit);
}
}, 2000);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'select_task', task_id: taskId }));
}
}
function openDoc(docId) {
const iframe = document.getElementById('doc-drawer-iframe');
const loading = document.getElementById('doc-drawer-loading');
const title = document.getElementById('doc-drawer-title');
const docUrl = `/doc/${docId}`;
// 重置状态
iframe.style.display = 'none';
loading.style.display = 'flex';
title.textContent = '📄 加载中...';
document.getElementById('doc-drawer').classList.add('open');
document.getElementById('doc-drawer-overlay').classList.add('open');
// 预加载文档标题
fetch(`/api/doc-name?doc_id=${docId}`)
.then(r => r.json())
.then(d => { if (d.name) title.textContent = `📄 ${d.name}`; })
.catch(() => {});
// 设置 iframe src(加载完成后自动显示)
iframe.onload = () => {
loading.style.display = 'none';
iframe.style.display = 'block';
// 尝试从iframe提取标题
try {
const docTitle = iframe.contentDocument?.title;
if (docTitle && !docTitle.includes('文档系统')) {
title.textContent = `📄 ${docTitle}`;
}
} catch(e) {}
};
iframe.src = docUrl;
}
function closeDocDrawer() {
document.getElementById('doc-drawer').classList.remove('open');
document.getElementById('doc-drawer-overlay').classList.remove('open');
// 延迟清空 iframe src,释放内存
setTimeout(() => {
const iframe = document.getElementById('doc-drawer-iframe');
if (!document.getElementById('doc-drawer').classList.contains('open')) {
iframe.src = '';
iframe.style.display = 'none';
document.getElementById('doc-drawer-loading').style.display = 'flex';
}
}, 300);
}
// Esc 键关闭抽屉
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDocDrawer();
});
function escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function renderMarkdown(text) {
if (!text) return '';
try {
return marked.parse(text, {
breaks: true,
gfm: true,
});
} catch(e) {
return escapeHtml(text);
}
}
function copyCode(btn) {
const pre = btn.closest('.code-block-wrapper').querySelector('pre code');
const text = pre.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '已复制';
setTimeout(() => { btn.textContent = '复制'; }, 2000);
}).catch(() => {
btn.textContent = '复制失败';
});
}
// 配置 marked 使用 highlight.js(marked v15 新写法)
const renderer = new marked.Renderer();
renderer.code = function({ text, lang }) {
let highlighted;
try {
if (lang && hljs.getLanguage(lang)) {
highlighted = hljs.highlight(text, { language: lang }).value;
} else {
highlighted = hljs.highlightAuto(text).value;
}
} catch(e) {
highlighted = text;
}
const langLabel = lang || 'code';
return `<div class="code-block-wrapper"><div class="code-header"><span class="code-lang">${langLabel}</span><button class="copy-btn" onclick="copyCode(this)">复制</button></div><pre><code class="language-${langLabel}">${highlighted}</code></pre></div>`;
};
marked.use({ renderer });
let streamBuffer = '';
let streamTimer = null;
let thinkingBuffer = '';
let reasoningText = '';
let pendingReasoningDone = false;
const ENABLE_REASONING = true;
// ── 无限滚动 ──
let historyOffset = 0;
let historyLimit = 20;
let isLoadingMore = false;
let hasMoreHistory = true;
// ── 推理折叠面板 ──
function renderReasoning(text, container) {
const panel = document.createElement('div');
panel.className = 'reasoning-panel';
panel.innerHTML = `
<div class="reasoning-header" onclick="toggleReasoning(this)">
<span class="arrow">▶</span>
<span class="icon">💭</span>
<span class="label">思考过程</span>
</div>
<div class="reasoning-body">
<div class="thinking-text">${escapeHtml(text)}</div>
</div>`;
container.appendChild(panel);
}
function toggleReasoning(header) {
const arrow = header.querySelector('.arrow');
const body = header.parentElement.querySelector('.reasoning-body');
arrow.classList.toggle('open');
body.classList.toggle('open');
}
// ── 实时更新推理内容 ──
function updateReasoningDisplay(chunk) {
// 找到当前消息中的推理面板,或创建新的
const lastMsg = messages.lastElementChild;
let panel = lastMsg ? lastMsg.querySelector('.reasoning-panel') : null;
if (panel) {
const textEl = panel.querySelector('.thinking-text');
if (textEl) textEl.textContent += chunk;
} else if (lastMsg && lastMsg.classList.contains('ai')) {
// 在消息最前面插入推理面板
panel = document.createElement('div');
panel.className = 'reasoning-panel';
panel.innerHTML = `
<div class="reasoning-header" onclick="toggleReasoning(this)">
<span class="arrow">▶</span>
<span class="icon">💭</span>
<span class="label">思考过程</span>
</div>
<div class="reasoning-body open">
<div class="thinking-text">${escapeHtml(chunk)}</div>
</div>`;
// 推理面板后面插入一个包裹剩余内容的容器
const contentWrap = document.createElement('div');
contentWrap.className = 'reasoning-content';
while (lastMsg.firstChild) {
contentWrap.appendChild(lastMsg.firstChild);
}
lastMsg.appendChild(panel);
// 把旧内容移到推理面板下方
panel.after(contentWrap);
}
}
// ── 最终完成推理(收起面板)──
function finalizeReasoning() {
const panels = messages.querySelectorAll('.reasoning-panel');
const lastPanel = panels[panels.length - 1];
if (lastPanel) {
const body = lastPanel.querySelector('.reasoning-body');
if (body) body.classList.remove('open');
const arrow = lastPanel.querySelector('.arrow');
if (arrow) arrow.classList.remove('open');
}
}
// ── 在消息底部加上打字光标 ──
function addTypingCursor(msgEl) {
let cursor = msgEl.querySelector('.typing-cursor');
if (!cursor) {
cursor = document.createElement('span');
cursor.className = 'typing-cursor';
const contentArea = msgEl.querySelector('.msg-content') || msgEl.querySelector('.tool-calls-area');
if (contentArea) contentArea.after(cursor);
else msgEl.appendChild(cursor);
}
}
function removeTypingCursor(msgEl) {
const cursor = msgEl.querySelector('.typing-cursor');
if (cursor) cursor.remove();
}
// ── 工具调用卡片 ──
let toolCallState = {
activeTool: null, // 当前正在运行的 tool 对象
toolIdCounter: 0,
};
function getToolIcon(name) {
const iconMap = {
'web_search': '🔍', 'web_search_news': '📰',
'terminal': '💻', 'read_file': '📖', 'write_file': '✏️',
'search_files': '🔎', 'patch': '🔧',
'execute_code': '⚡', 'python': '🐍',
'vision_analyze': '👁️', 'text_to_speech': '🔊',
'memory': '🧠', 'skill_manage': '📦', 'delegate_task': '👥',
'cronjob': '⏰', 'todo': '✅',
'browser': '🌐', 'clarify': '❓',
};
return iconMap[name] || '🔧';
}
function createToolCard(name, args) {
toolCallState.toolIdCounter++;
const toolId = toolCallState.toolIdCounter;
const card = document.createElement('div');
card.className = 'tool-call-card';
card.dataset.toolId = toolId;
const icon = getToolIcon(name);
let argsHtml = '';
if (args && typeof args === 'object') {
const lines = [];
for (const [k, v] of Object.entries(args)) {
const val = typeof v === 'string' ? v : JSON.stringify(v);
lines.push(`<span class="tool-args-key">${escapeHtml(k)}</span>=<span class="tool-args-val">${escapeHtml(String(val).substring(0, 100))}</span>`);
}
if (lines.length) argsHtml = '<div class="tool-args">' + lines.join(', ') + '</div>';
}
card.innerHTML = `
<div class="tool-call-header" onclick="toggleToolCard(this)">
<span class="tool-icon">${icon}</span>
<span class="tool-name">${escapeHtml(name)}</span>
<span class="tool-status running">
<span class="tool-status-spinner"></span> 运行中…
</span>
</div>
<div class="tool-call-body">
${argsHtml}
<div class="tool-result"></div>
</div>`;
toolCallState.activeTool = { card, name, toolId, status: 'running' };
return card;
}
function updateToolResult(text) {
if (!toolCallState.activeTool) return;
const body = toolCallState.activeTool.card.querySelector('.tool-call-body');
const resultDiv = body.querySelector('.tool-result');
if (resultDiv) {
const preview = text.length > 200 ? text.substring(0, 200) + '…' : text;
resultDiv.innerHTML = `<div class="tool-result-preview">${escapeHtml(preview)}</div>`;
}
}
function completeActiveTool() {
if (!toolCallState.activeTool) return;
const card = toolCallState.activeTool.card;
const statusEl = card.querySelector('.tool-status');
statusEl.className = 'tool-status done';
statusEl.innerHTML = '✅ 完成';
const spinner = statusEl.querySelector('.tool-status-spinner');
if (spinner) spinner.remove();
toolCallState.activeTool = null;
}
// ── 子代理事件处理 ──
let subagentCards = {}; // subagent_id -> card element
function handleSubagentEvent(data) {
const saType = data.subagent_type;
const saId = data.subagent_id || 'default';
const msgEl = getOrCreateAiMessage();
const callsArea = getOrCreateCallsArea(msgEl);
let card = subagentCards[saId];
if (saType === 'start') {
// 创建新卡片
card = document.createElement('div');
card.className = 'subagent-card';
card.dataset.saId = saId;
card.innerHTML = `
<div class="subagent-header">
<span class="sa-icon">👤</span>
<span class="sa-name">${escapeHtml(data.name || '子任务')}</span>
<span class="sa-badge">${data.task_index + 1}/${data.task_count}</span>
<span style="margin-left:auto;font-size:11px;color:#f59e0b;">🔄 运行中</span>
</div>
<div class="subagent-body"></div>
<div class="subagent-footer" style="display:none;"></div>`;
callsArea.appendChild(card);
subagentCards[saId] = card;
messages.scrollTop = messages.scrollHeight;
} else if (!card) {
// 如果没有 start 事件但收到后续事件,创建骨架
card = document.createElement('div');
card.className = 'subagent-card';
card.dataset.saId = saId;
card.innerHTML = `
<div class="subagent-header">
<span class="sa-icon">👤</span>
<span class="sa-name">子任务</span>
<span style="margin-left:auto;font-size:11px;color:#f59e0b;">🔄 运行中</span>
</div>
<div class="subagent-body"></div>
<div class="subagent-footer" style="display:none;"></div>`;
callsArea.appendChild(card);
subagentCards[saId] = card;
}
const body = card.querySelector('.subagent-body');
const footer = card.querySelector('.subagent-footer');
switch (saType) {
case 'thinking':
if (body) {
const line = document.createElement('div');
line.className = 'sa-thinking';
line.textContent = '💭 ' + (data.text || '');
body.appendChild(line);
body.scrollTop = body.scrollHeight;
}
break;
case 'text':
if (body) {
body.appendChild((() => {
const el = document.createElement('div');
el.className = 'sa-text';
el.textContent = data.text || '';
return el;
})());
body.scrollTop = body.scrollHeight;
}
break;
case 'tool':
if (body) {
body.appendChild((() => {
const el = document.createElement('div');
el.className = 'sa-tool';
el.textContent = '🔧 ' + (data.tool_name || '') + (data.text ? ': ' + data.text : '');
return el;
})());
body.scrollTop = body.scrollHeight;
}
break;
case 'complete':
// 更新头部状态
const header = card.querySelector('.subagent-header');
if (header) {
const statusSpan = header.querySelector('[style*="margin-left:auto"]') || header.lastElementChild;
if (statusSpan) {
statusSpan.textContent = '✅ 完成';
statusSpan.style.color = '#4ade80';
}
}
// 显示 footer 统计
if (footer) {
footer.style.display = 'flex';
const stats = [];
if (data.input_tokens) stats.push(`📥 ${data.input_tokens} tokens`);
if (data.output_tokens) stats.push(`📤 ${data.output_tokens} tokens`);
if (data.summary) stats.push(`📋 ${data.summary.substring(0, 60)}`);
footer.innerHTML = stats.map(s => `<span class="sa-stat">${s}</span>`).join('');
}
// 延迟清理
setTimeout(() => delete subagentCards[saId], 5000);
break;
}
}
function getOrCreateAiMessage() {
let last = messages.lastElementChild;
if (last && last.classList.contains('ai') && last.dataset.streaming === 'true') {
return last;
}
// 创建一个新的 AI 消息容器
const div = document.createElement('div');
div.className = 'msg ai';
div.dataset.streaming = 'true';
const callsArea = document.createElement('div');
callsArea.className = 'tool-calls-area';
div.appendChild(callsArea);
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
div.appendChild(contentDiv);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date().toLocaleTimeString();
div.appendChild(time);
messages.appendChild(div);
return div;
}
function getOrCreateCallsArea(msgEl) {
let area = msgEl.querySelector('.tool-calls-area');
if (!area) {
area = document.createElement('div');
area.className = 'tool-calls-area';
msgEl.insertBefore(area, msgEl.querySelector('.msg-content') || msgEl.firstChild);
}
return area;
}
function toggleToolCard(header) {
const card = header.parentElement;
card.classList.toggle('collapsed');
}
function appendStream(chunk) {
streamBuffer += chunk;
if (streamTimer) return;
streamTimer = setTimeout(() => {
streamTimer = null;
const full = streamBuffer.trim();
streamBuffer = '';
// 如果已切换任务或内容为空,丢弃
if (!messages.children.length || !full) return;
const last = messages.lastElementChild;
if (last && last.classList.contains('ai') && last.dataset.streaming === 'true') {
// 只更新 msg-content,不破坏 tool-calls-area
let contentDiv = last.querySelector('.msg-content');
if (!contentDiv) {
contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
// 插入到 tool-calls-area 之后或消息开头
const callsArea = last.querySelector('.tool-calls-area');
if (callsArea) {
callsArea.after(contentDiv);
} else {
last.prepend(contentDiv);
}
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date().toLocaleTimeString();
last.appendChild(time);
}
contentDiv.innerHTML = renderMarkdown(full);
addTypingCursor(last);
} else {
const div = document.createElement('div');
div.className = 'msg ai';
div.dataset.streaming = 'true';
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
contentDiv.innerHTML = renderMarkdown(full);
div.appendChild(contentDiv);
addTypingCursor(div);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date().toLocaleTimeString();
div.appendChild(time);
messages.appendChild(div);
}
// 对新的代码块做高亮
messages.querySelectorAll('pre code[class*="language-"]').forEach(el => {
if (!el.dataset.highlighted) {
hljs.highlightElement(el);
el.dataset.highlighted = 'true';
}
});
messages.scrollTop = messages.scrollHeight;
}, 50);
}
// ═══════════════════════════════════════════
// WebSocket
// ═══════════════════════════════════════════
function connect() {
ws = new WebSocket(WS_URL);
status.textContent = '● 连接中...';
status.className = 'offline';
ws.onopen = () => {
status.textContent = '● 在线';
status.className = 'online';
input.disabled = false;
sendBtn.disabled = false;
input.focus();
// 重试等待 currentTaskId 就绪(loadTasks 异步恢复可能还没完成)
const waitForTask = () => {
if (currentTaskId) {
ws.send(JSON.stringify({ type: 'select_task', task_id: currentTaskId }));
// 页面刷新:优先从缓存恢复,后台同步
const cached = cacheGet(currentTaskId);
if (cached && cached.length > 0) {
hasMoreHistory = true;
renderMessages(cached, false);
setTimeout(() => loadHistory(currentTaskId, 0, historyLimit), 500);
} else {
setTimeout(() => loadHistory(currentTaskId, 0, historyLimit), 300);
}
} else {
// loadTasks 还没跑完,等 500ms 再试
setTimeout(waitForTask, 500);
}
};
waitForTask();
};
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
// ── 任务隔离检查 ──
// 所有携带 task_id 的消息,如果 task_id 与当前选中任务不一致则忽略
if (data.task_id && data.task_id !== currentTaskId) {
if (data.type === 'reply_done' || data.type === 'reply_chunk') {
pendingUpdates.add(data.task_id);
}
return;
}
switch (data.type) {
case 'task_selected':
break;
case 'ack':
break;
case 'reply_chunk':
// 获取 stage(streaming / thinking / tool_start / tool_complete / thinking_start)
const stage = data.stage || 'streaming';
if (stage === 'thinking_start') {
// 开始推理:记录当前流式任务
streamingTaskId = data.task_id || currentTaskId;
reasoningText = '';
pendingReasoningDone = false;
break;
}
if (stage === 'thinking') {
appendStream(data.chunk);
break;
}
if (stage === 'tool_start') {
// 创建工具调用卡片
const toolData = data.tool || {};
const card = createToolCard(
toolData.name || data.chunk.replace(/.*?\[(.*?)\].*/, '$1') || 'tool',
toolData.args || {}
);
// 插入到当前消息
const lastMsg = messages.lastElementChild;
if (lastMsg && lastMsg.classList.contains('ai') && lastMsg.dataset.streaming === 'true') {
let callsArea = lastMsg.querySelector('.tool-calls-area');
if (!callsArea) {
callsArea = document.createElement('div');
callsArea.className = 'tool-calls-area';
lastMsg.insertBefore(callsArea, lastMsg.querySelector('.msg-content') || lastMsg.firstChild);
}
callsArea.appendChild(card);
} else {
// 还没消息区域,创建一个 AI 消息并插入卡片
const div = document.createElement('div');
div.className = 'msg ai';
div.dataset.streaming = 'true';
const callsArea = document.createElement('div');
callsArea.className = 'tool-calls-area';
callsArea.appendChild(card);
div.appendChild(callsArea);
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
div.appendChild(contentDiv);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date().toLocaleTimeString();
div.appendChild(time);
messages.appendChild(div);
}
messages.scrollTop = messages.scrollHeight;
break;
}
if (stage === 'tool_complete') {
completeActiveTool();
break;
}
// 默认:普通流式文本
appendStream(data.chunk);
break;
case 'reasoning_delta':
if (ENABLE_REASONING) {
reasoningText += data.text || '';
// 实时更新推理面板
updateReasoningDisplay(data.text || '');
}
break;
case 'reasoning_done':
if (ENABLE_REASONING && reasoningText) {
pendingReasoningDone = true;
finalizeReasoning();
}
break;
case 'subagent_event':
handleSubagentEvent(data);
break;
case 'reply_done':
streamingTaskId = null;
if (streamTimer) {
clearTimeout(streamTimer);
streamTimer = null;
}
// flush 残留 buffer:仅更新 msg-content,不破坏结构
if (streamBuffer) {
const pendingText = streamBuffer;
streamBuffer = '';
const lastPending = messages.lastElementChild;
if (lastPending && lastPending.classList.contains('ai') && lastPending.dataset.streaming === 'true') {
let contentDiv = lastPending.querySelector('.msg-content');
if (contentDiv) {
contentDiv.innerHTML = renderMarkdown(pendingText);
} else {
// 没有 contentDiv,退而用 innerHTML(只保留 time)
let html = lastPending.innerHTML;
const timeIdx = html.indexOf('<div class="time"');
html = renderMarkdown(pendingText) + (timeIdx > -1 ? html.substring(timeIdx) : '');
lastPending.innerHTML = html;
}
addTypingCursor(lastPending);
lastPending.querySelectorAll('pre code[class*="language-"]').forEach(el => {
if (!el.dataset.highlighted) { hljs.highlightElement(el); el.dataset.highlighted = 'true'; }
});
}
}
// 用最终回复覆盖
const finalReply = data.reply || '';
if (finalReply) {
const lastMsg = messages.lastElementChild;
if (lastMsg && lastMsg.classList.contains('ai')) {
let contentDiv = lastMsg.querySelector('.msg-content');
if (contentDiv) {
contentDiv.innerHTML = renderMarkdown(finalReply);
} else {
const newContent = document.createElement('div');
newContent.className = 'msg-content';
newContent.innerHTML = renderMarkdown(finalReply);
const timeEl = lastMsg.querySelector('.time');
if (timeEl) lastMsg.insertBefore(newContent, timeEl);
else lastMsg.appendChild(newContent);
}
contentDiv = lastMsg.querySelector('.msg-content');
if (contentDiv) {
contentDiv.querySelectorAll('pre code[class*="language-"]').forEach(el => {
if (!el.dataset.highlighted) { hljs.highlightElement(el); el.dataset.highlighted = 'true'; }
});
}
let timeEl = lastMsg.querySelector('.time');
if (!timeEl) {
timeEl = document.createElement('div');
timeEl.className = 'time';
lastMsg.appendChild(timeEl);
}
timeEl.textContent = new Date().toLocaleTimeString();
lastMsg.dataset.streaming = 'false';
removeTypingCursor(lastMsg);
} else {
const div = document.createElement('div');
div.className = 'msg ai';
div.dataset.streaming = 'false';
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
contentDiv.innerHTML = renderMarkdown(finalReply);
div.appendChild(contentDiv);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date().toLocaleTimeString();
div.appendChild(time);
messages.appendChild(div);
}
messages.scrollTop = messages.scrollHeight;
// ✨ 缓存到浏览器(只存核心字段,丢弃思考过程等大字段)
if (currentTaskId && finalReply) {
cacheAppend(currentTaskId, {
id: data.message_id || 'ws_' + Date.now(),
role: 'assistant',
content: finalReply,
timestamp: Date.now() / 1000,
});
}
}
typing.textContent = '';
input.disabled = false;
sendBtn.disabled = false;
input.focus();
break;
case 'error':
streamingTaskId = null;
addSystemMsg('⚠️ ' + (data.message || '未知错误'));
typing.textContent = '';
input.disabled = false;
sendBtn.disabled = false;
break;
}
} catch(e) {
appendStream(e.data);
}
};
ws.onclose = () => {
status.textContent = '● 离线';
status.className = 'offline';
input.disabled = true;
sendBtn.disabled = true;
setTimeout(connect, 3000);
};
ws.onerror = () => {
status.textContent = '● 连接失败';
status.className = 'offline';
};
}
function addSystemMsg(text) {
const div = document.createElement('div');
div.className = 'msg system';
div.textContent = text;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
async function sendMessage() {
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN || !currentTaskId) return;
// P1: 先保留到 state.db,确保切 task 不丢消息
try {
await fetch(`${API_BASE}/chat/message/retain`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: currentTaskId, role: 'user', content: text }),
});
// ✨ 缓存用户消息
cacheAppend(currentTaskId, {
id: 'retain_' + Date.now(),
role: 'user',
content: text,
timestamp: Date.now() / 1000,
});
} catch(e) {
console.warn('retain failed (non-fatal):', e);
}
// 乐观渲染
const div = document.createElement('div');
div.className = 'msg user';
div.innerHTML = escapeHtml(text);
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date().toLocaleTimeString();
div.appendChild(time);
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
msgId++;
ws.send(JSON.stringify({
type: 'message',
msg_id: msgId,
content: text,
task_id: currentTaskId,
}));
input.value = '';
typing.textContent = '助手正在输入';
typing.className = 'typing-dots';
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); sendMessage(); }
});
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
});
sendBtn.addEventListener('click', sendMessage);
// ═══════════════════════════════════════════
// 附件上传
// ═══════════════════════════════════════════
async function handleFileSelect(input) {
const file = input.files && input.files[0];
if (!file) return;
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
addSystemMsg(`⚠️ 文件过大(最大 ${maxSize/1024/1024}MB)`);
input.value = '';
return;
}
const nameEl = document.getElementById('upload-name');
const btn = document.getElementById('upload-btn');
const bar = document.getElementById('upload-progress-bar');
nameEl.textContent = `📎 ${file.name}`;
btn.classList.add('uploading');
bar.style.width = '30%';
try {
const formData = new FormData();
formData.append('file', file);
formData.append('task_id', currentTaskId || '');
const resp = await fetch(`${API_BASE}/chat/attachment/upload`, {
method: 'POST',
body: formData,
});
bar.style.width = '90%';
const data = await resp.json();
if (data.ok) {
// 在输入框插入文件链接
const link = `[${data.filename}](${data.url})`;
const textarea = document.getElementById('input');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = textarea.value.substring(0, start);
const after = textarea.value.substring(end);
textarea.value = before + (before && !before.endsWith(' ') ? ' ' : '') + link + (after && !after.startsWith(' ') ? ' ' : '') + after;
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + link.length + 1;
textarea.dispatchEvent(new Event('input'));
nameEl.textContent = '✅ 已添加';
} else {
addSystemMsg(`⚠️ 上传失败: ${data.error || '未知错误'}`);
nameEl.textContent = '';
}
} catch(e) {
addSystemMsg(`⚠️ 上传失败: ${e.message}`);
nameEl.textContent = '';
} finally {
bar.style.width = '100%';
setTimeout(() => {
btn.classList.remove('uploading');
bar.style.width = '0%';
}, 500);
input.value = '';
}
}
// ═══════════════════════════════════════════
// 移动消息到另一个 task
// ═══════════════════════════════════════════
let moveMessageId = null;
let moveTaskId = null;
function showMoveMessage(msgId) {
moveMessageId = msgId;
moveTaskId = currentTaskId;
// 填充任务列表(排除当前 task)
const sel = document.getElementById('move-task-select');
sel.innerHTML = '';
const available = tasks.filter(t => t.id !== currentTaskId);
if (available.length === 0) {
sel.innerHTML = '<option value="">— 没有其他任务 —</option>';
sel.disabled = true;
} else {
available.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
sel.appendChild(opt);
});
sel.disabled = false;
}
document.getElementById('move-overlay').style.display = 'flex';
}
function confirmMoveMessage() {
const sel = document.getElementById('move-task-select');
const targetId = sel.value;
if (!targetId) return;
const overlay = document.getElementById('move-overlay');
overlay.style.display = 'none';
fetch('/api/chat/messages/move', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ message_id: parseInt(moveMessageId), target_task_id: targetId }),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
if (data.moved) {
const el = document.querySelector(`[data-msg-id="${moveMessageId}"]`);
if (el) el.remove();
}
loadWiki(currentTaskId);
} else {
alert('移动失败: ' + (data.error || 'unknown'));
}
})
.catch(e => alert('网络错误: ' + e.message));
}
function cancelMoveMessage() {
document.getElementById('move-overlay').style.display = 'none';
moveMessageId = null;
}
loadTasks();
connect();
setInterval(loadTasks, 30000);
// ── 无限滚动监听 ──
messages.addEventListener('scroll', function() {
if (this.scrollTop < 80 && hasMoreHistory && !isLoadingMore && !loadingTaskId) {
loadMoreHistory();
}
});
</script>
<!-- 移动消息选择器 -->
<div id="move-overlay" class="move-overlay" style="display:none;">
<div class="move-modal">
<div class="move-modal-title">📨 移动到其他任务</div>
<select id="move-task-select" class="move-task-select"></select>
<div class="move-modal-actions">
<button class="btn-cancel" onclick="cancelMoveMessage()">取消</button>
<button class="btn-confirm" onclick="confirmMoveMessage()">移动</button>
</div>
</div>
</div>
</body>
</html>