// 对话视图(任务列表 + 选择/渲染消息 + 发送/SSE 流式 + 任务生命周期):
// 任务列表浏览/筛选/滚动加载、selectTask 切换、renderChatMeta、模型下拉、
// renderMessages、live-run 助手、sendMessage/cancel、fetchSse/handleSseEvent、
// 润色/粘贴文件、完成/废弃/删除/导出/清空。共享 state.liveRuns + chat-stream DOM。
// 各入口模块顶层自绑;对外导出 loadTaskList / loadModels / selectTask
// (供 main enterApp、embed、files、newtask)。
import { state } from "./state.js";
import { $, showMenu } from "./dom.js";
import { api } from "./api.js";
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
import { mqPhone, setMobileView } from "./layout.js";
import { logout } from "./auth.js";
import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";

export async function loadModels() {
  try {
    const data = await api("GET", "/v1/models");
    state.models = data.models || [];
  } catch (e) {
    state.models = [];  // 静默兜底:无模型清单时下拉不显示,不挡正常流程
  }
  try {
    const data = await api("GET", "/v1/image_models");
    state.imageModels = data.models || [];
    // 默认锁定第一个(=agent_builder fallback);用户后续切换就会更新
    if (!state.imageModel) {
      const def = state.imageModels.find(m => m.is_default) || state.imageModels[0];
      state.imageModel = def ? def.variant : "";
    }
  } catch (e) {
    state.imageModels = [];
    state.imageModel = "";
  }
  try {
    const data = await api("GET", "/v1/video_models");
    state.videoModels = data.models || [];
    if (!state.videoModel) {
      const def = state.videoModels.find(m => m.is_default) || state.videoModels[0];
      state.videoModel = def ? def.variant : "";
    }
  } catch (e) {
    state.videoModels = [];
    state.videoModel = "";
  }
  // embed + task_id 场景下 selectTask 可能在 loadModels 完成前就跑完 renderChatMeta,
  // 此时 models 为空 → 模型下拉不渲染。loadModels 收尾时如果已选中 task,补一次 chat-meta 重渲。
  if (state.taskMeta) renderChatMeta();
}

// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应
let _taskLoadSeq = 0;
export async function loadTaskList({ append = false } = {}) {
  if (append && (state.taskLoading || !state.taskHasMore)) return;
  const mySeq = ++_taskLoadSeq;
  const nextPage = append ? state.taskPage + 1 : 1;
  const params = new URLSearchParams();
  params.set("page", nextPage);
  params.set("page_size", state.taskPageSize);
  const st = $("filter-status").value;
  if (st) params.set("status", st);
  const q = $("filter-q").value.trim();
  if (q) params.set("q", q);
  const wd = $("filter-wd").value.trim();
  if (wd) params.set("working_dir", wd);
  const ord = $("filter-order").value;
  if (ord && ord !== "-created_at") params.set("ordering", ord);  // 默认值不发送,URL 更干净
  state.taskLoading = true;
  setSentinel(append ? "加载中…" : "");
  try {
    const data = await api("GET", "/v1/tasks?" + params.toString());
    if (mySeq !== _taskLoadSeq) return;  // 已被更新的请求 supersede,丢弃
    state.taskTotal = data.count || 0;
    state.taskPage = data.page || nextPage;
    state.taskPageSize = data.page_size || state.taskPageSize;
    const results = data.results || [];
    if (!append) state.taskLoaded = 0;
    state.taskLoaded += results.length;
    state.taskHasMore = state.taskLoaded < state.taskTotal;
    renderTaskList(results, append);
    renderTaskCount();
  } catch (e) {
    if (mySeq !== _taskLoadSeq) return;
    if (e.status === 401) { logout(); return; }
    if (!append) {
      $("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
      state.taskHasMore = false;
    }
    setSentinel(`加载失败:${e.message}`);
  } finally {
    if (mySeq === _taskLoadSeq) state.taskLoading = false;
  }
}

function renderTaskCount() {
  $("task-count").textContent = state.taskTotal > 0 ? `共 ${state.taskTotal} 个` : "";
  if (state.taskTotal === 0) setSentinel("");
  else if (!state.taskHasMore) setSentinel(state.taskPage > 1 ? "— 已加载全部 —" : "");
  else setSentinel("");  // 还有更多 → 留空,observer 触发时再填"加载中"
}

function setSentinel(text) {
  $("task-sentinel").textContent = text || "";
}

function renderTaskList(tasks, append = false) {
  if (!append) state.tasksById = {};
  for (const t of tasks) state.tasksById[t.task_id] = t;
  if (!append && !tasks.length) {
    $("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
    return;
  }
  if (append && !tasks.length) return;  // 末页空 batch,不动 DOM
  const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
  const html = tasks.map((t) => {
    const active = state.taskId === t.task_id ? " active" : "";
    // 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
    const taskName = t.name || "(未命名)";
    const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
    const desc = t.description || "";
    const statusLabel = statusLabels[t.status] || t.status;
    const rowTitle = `${taskName}\n${t.task_id}`;  // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8)
    return `
      <div class="task-row${active}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;">
        <div style="flex:1;min-width:0;">
          <div class="desc">${escapeHtml(taskName)}</div>
          ${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📁 ${escapeHtml(wdName)}</div>` : ""}
          ${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""}
          <div class="meta">
            <span class="badge ${t.status}">${statusLabel}</span>
            ${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""}
            <span class="num right-group">${t.n_messages || 0} 条</span>
            <span class="num" title="${escapeHtml(taskUsageTooltip(t))}">${fmtTokens(t.tokens)} tok</span>
            <span class="muted time-ago" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span>
          </div>
        </div>
        <button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作">⋯</button>
      </div>
    `;
  }).join("");
  const listEl = $("task-list");
  let newRows;
  if (append) {
    const tmp = document.createElement("div");
    tmp.innerHTML = html;
    newRows = Array.from(tmp.children);
    newRows.forEach((el) => listEl.appendChild(el));
  } else {
    listEl.innerHTML = html;
    newRows = Array.from(listEl.querySelectorAll(".task-row"));
  }
  newRows.forEach((el) => {
    if (!el.classList || !el.classList.contains("task-row")) return;
    el.onclick = (e) => {
      if (e.target.closest(".dd-toggle")) return;  // 菜单按钮点击不触发选中
      selectTask(el.dataset.tid);
    };
    const btn = el.querySelector(".task-menu");
    if (btn) {
      btn.onclick = (e) => {
        e.stopPropagation();
        const t = state.tasksById[btn.dataset.tid];
        if (!t) return;
        showMenu(btn, taskMenuItems(t));
      };
    }
  });
}

function taskMenuItems(t) {
  const isActive = t.status === "active";
  const hasMsg = (t.n_messages || 0) > 0;
  return [
    { act: "complete", label: "完成",       cls: "act-complete", disabled: !isActive,
      onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") },
    { act: "abandon",  label: "废弃",       cls: "act-abandon",  disabled: !isActive,
      onclick: () => setTaskStatus(t.task_id, "abandoned", t.name || "(未命名)") },
    { act: "export",   label: "导出对话记录",  cls: "act-export",   disabled: !hasMsg,
      onclick: () => exportTask(t.task_id) },
    { act: "delete",   label: "删除",       cls: "act-delete",
      onclick: () => deleteTask(t.task_id, t.name || "(未命名)", t.n_messages || 0) },
  ];
}

// 筛选 / 排序 / 刷新 一律 reset(loadTaskList 默认 append=false);追加由 sentinel observer 触发
$("filter-status").onchange = () => loadTaskList();
$("filter-order").onchange = () => loadTaskList();
$("filter-wd").onchange = () => loadTaskList();   // select 选完立即筛
$("btn-refresh-tasks").onclick = () => loadTaskList();

// 搜索 q 是 text input → 300ms debounce 避免每字符打 API
let _filterDebounce = null;
$("filter-q").addEventListener("input", () => {
  clearTimeout(_filterDebounce);
  _filterDebounce = setTimeout(() => loadTaskList(), 300);
});

// 滚动加载:只让 task 列表区域滚,顶部标题 / 新建 / 筛选 / 排序固定。
// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖)
const _taskScrollObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) {
    loadTaskList({ append: true });
  }
}, { root: $("task-scroll"), rootMargin: "200px 0px" });
_taskScrollObserver.observe($("task-sentinel"));

// ───── select task ─────
export async function selectTask(tid) {
  if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
  // 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
  // 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
  if (state.taskId && state.taskId !== tid) _flushMediaArtifactCache();
  state.taskId = tid;
  document.querySelectorAll(".task-row").forEach((el) => {
    el.classList.toggle("active", el.dataset.tid === tid);
  });
  // 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
  if (mqPhone.matches) setMobileView("mv-mid");
  try {
    const meta = await api("GET", "/v1/tasks/" + tid);
    state.taskMeta = meta;
    renderChatMeta();
    await loadMessages();
    if (meta.run_status === "running" || meta.run_status === "cancelling") {
      ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
    } else {
      renderLiveRunIfVisible();
    }
    // 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
    // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
    const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
    state.filesPath = wdName || "";
    await loadFiles();
    refreshConcurrentWarnings();  // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    $("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
  }
}

// 拉同 wd 内除自己外仍 running/cancelling 的 task,渲染软警告 banner。
// 同 wd 多 task 同时跑频率近 0(用户工作流以"同项目对话历史轨迹"为主,不并发),
// 这里只做提示,不挡发送(对应 DESIGN §7.8 / §7.9 "信任 + 软警告 + 承认边界")。
async function refreshConcurrentWarnings() {
  const t = state.taskMeta;
  if (!t || !t.working_dir) { state.concurrentWarnings = []; renderConcurrentWarning(); return; }
  const wdName = t.working_dir.split("/").filter(Boolean).pop();
  if (!wdName) { state.concurrentWarnings = []; renderConcurrentWarning(); return; }
  const params = new URLSearchParams();
  params.set("working_dir", wdName);
  params.set("run_status", "running,cancelling");
  params.set("page_size", "10");
  try {
    const data = await api("GET", "/v1/tasks?" + params.toString());
    const others = (data.results || []).filter(r => r.task_id !== t.task_id);
    state.concurrentWarnings = others;
  } catch (e) {
    // 警告失败不影响主功能,静默
    state.concurrentWarnings = [];
  }
  renderConcurrentWarning();
}

function renderConcurrentWarning() {
  const el = $("wd-concurrent-warn");
  const others = state.concurrentWarnings;
  if (!others || others.length === 0) { el.style.display = "none"; el.innerHTML = ""; return; }
  const head = others[0];
  const t = state.taskMeta;
  const wdName = t && t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
  const more = others.length > 1 ? ` 等 ${others.length} 个` : "";
  el.innerHTML = `⚠ 项目 "${escapeHtml(wdName)}" 内 task <span class="tname">${escapeHtml(head.name || "(未命名)")}</span> 正在 <span class="rs">${escapeHtml(head.run_status)}</span>${more} — 并发写同名中间产物可能互覆,建议等它结束再发`;
  el.style.display = "block";
}

function renderChatMeta() {
  const t = state.taskMeta;
  if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; }
  const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
  const taskName = t.name || "(未命名)";
  const statusLabel = { active: "进行中", completed: "已完成", abandoned: "已废弃" }[t.status] || t.status;
  // wdName 与 taskName 相同时(留空 fallback,多数场景)不重复显示 📁;
  // 不同时(用户显式指定共享目录 / 改了 name)才挂 📁,提示"项目归属"
  const wdBadge = (wdName && wdName !== taskName)
    ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>`
    : "";
  $("chat-meta").innerHTML = `
    <span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
    <span class="badge ${t.status}">${statusLabel}</span>
    ${wdBadge}
    ${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
    <span class="tid">${t.task_id.slice(0, 8)}</span>
    ${formatTaskUsage(t)}
    ${t.description ? `<span class="muted desc">${escapeHtml(t.description)}</span>` : ""}
    <span class="spacer"></span>
    ${renderModelDropdown(t)}
    ${renderImageModelDropdown()}
    ${renderVideoModelDropdown()}
  `;
  const sel = $("chat-model-sel");
  if (sel) sel.onchange = onChangeModel;
  const imgSel = $("chat-image-model-sel");
  if (imgSel) imgSel.onchange = onChangeImageModel;
  const vidSel = $("chat-video-model-sel");
  if (vidSel) vidSel.onchange = onChangeVideoModel;
  const active = t.status === "active";
  $("chat-form").style.display = active ? "flex" : "none";
  syncOptimizeBtn();
  $("btn-done").disabled = !active;
  $("btn-abandon").disabled = !active;
  $("btn-delete-task").disabled = false;  // delete 不限 status(用户显式 confirm)
  // 导出 / 清空:只要选中 task 就允许点(不按 n_messages 门禁 —— 历史 bug:
  // 清空后 n_messages=0 disable,但新对话进来后 taskMeta 不重渲一直 disable;
  // 0 条时点击不会出错(导出空 docx / 清空 confirm 显 0 条),让 UX 一致更省心)。
  $("btn-export").disabled = false;
  // 清空对话:仅活跃 run 期间禁用(后端 409,confirm 通过后才报错 UX 差)
  const running = t.run_status === "running" || t.run_status === "cancelling";
  $("btn-clear-msgs").disabled = running;
}

function renderModelDropdown(t) {
  // 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile)
  if (!state.models || state.models.length === 0) return "";
  const cur = t.model_profile || "";
  const opts = state.models.map(m =>
    `<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
  ).join("");
  return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">模型</span><span class="mdl-icon" aria-hidden="true">💬</span><select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
}

function renderImageModelDropdown() {
  // imageModels 为空(yaml 无 image variant)→ 不画下拉。注意不依赖 ARK_API_KEY 是否设了
  // —— 这里只是展示元数据,真正调用时 backend 那边没 key 自然 tool 不挂(用户不会
  // 在没 key 的环境点出图,prompt 里 seedream 工具压根不在 schema)。
  if (!state.imageModels || state.imageModels.length === 0) return "";
  const cur = state.imageModel || "";
  const opts = state.imageModels.map(m =>
    `<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
  ).join("");
  return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生图</span><span class="mdl-icon" aria-hidden="true">🖼</span><select id="chat-image-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生图时使用的模型(本地选择,不入库)">${opts}</select></span>`;
}

function onChangeImageModel(ev) {
  // 纯前端 state,不 PATCH;选中值随下一次 POST /v1/tasks/{id}/messages 的 image_model 字段一起发
  state.imageModel = ev.target.value || "";
  $("chat-hint").textContent = `生图模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
}

function renderVideoModelDropdown() {
  // 同 renderImageModelDropdown:videoModels 为空 → 不画。yaml 无 video 段 / 后端
  // /v1/video_models 返空时下拉不出现,seedance tool 也不会在 schema 里。
  if (!state.videoModels || state.videoModels.length === 0) return "";
  const cur = state.videoModel || "";
  const opts = state.videoModels.map(m =>
    `<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
  ).join("");
  return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生视频</span><span class="mdl-icon" aria-hidden="true">🎬</span><select id="chat-video-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生视频时使用的模型(本地选择,不入库)">${opts}</select></span>`;
}

function onChangeVideoModel(ev) {
  state.videoModel = ev.target.value || "";
  $("chat-hint").textContent = `生视频模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
}

async function onChangeModel(ev) {
  const sel = ev.target;
  const newProfile = sel.value;
  const t = state.taskMeta;
  if (!t || !newProfile || newProfile === t.model_profile) return;
  const oldProfile = t.model_profile || "";
  try {
    const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile });
    state.taskMeta = updated;
    const running = updated.run_status === "running" || updated.run_status === "cancelling";
    $("chat-hint").textContent = running
      ? `已切到 ${newProfile} · 当前 run 跑完后生效`
      : `已切到 ${newProfile}`;
  } catch (e) {
    sel.value = oldProfile;  // PATCH 失败 UI 回滚
    $("chat-hint").textContent = `切换失败:${e.message}`;
  }
}

async function loadMessages() {
  const data = await api("GET", `/v1/tasks/${state.taskId}/messages`);
  renderMessages(data.messages);
}

function getLiveRun(taskId) {
  return taskId ? state.liveRuns.get(taskId) : null;
}

function isCurrentTaskStreaming() {
  return !!getLiveRun(state.taskId);
}

function createLiveAssistantCard(run) {
  const card = document.createElement("div");
  card.className = "msg assistant live-run";
  card.innerHTML = `<div class="role">助手</div><div class="body streaming">${run.acc ? renderMd(run.acc) : ""}</div>`;
  run.card = card;
  run.body = card.querySelector(".body");
  return card;
}

function renderLiveRunIfVisible() {
  const run = getLiveRun(state.taskId);
  if (!run) {
    setActionMode("idle");
    return;
  }
  const wrap = $("chat-stream");
  const card = run.card || createLiveAssistantCard(run);
  if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
  if (card.parentElement !== wrap) wrap.appendChild(card);
  wrap.scrollTop = wrap.scrollHeight;
  setActionMode(run.cancelling ? "cancelling" : "streaming");
  $("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…";
}

function ensureRunningTaskSubscribed(taskId, url) {
  if (!taskId || getLiveRun(taskId)) return;
  const run = {
    taskId,
    url,
    acc: "",
    pending: false,
    seenRels: new Set(),
    terminal: false,
    card: null,
    body: null,
    cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
    workingDir: state.taskMeta && state.taskMeta.working_dir,
  };
  state.liveRuns.set(taskId, run);
  state.streaming = true;
  renderLiveRunIfVisible();
  streamSse(url, run);
}

function setRunHint(run, text) {
  if (state.taskId === run.taskId) $("chat-hint").textContent = text;
}

function renderMessages(msgs) {
  const wrap = $("chat-stream");
  wrap.innerHTML = "";
  if (!msgs.length) {
    wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
    renderLiveRunIfVisible();
    return;
  }
  // 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔
  // (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。
  let lastAsstModel = null;
  // chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
  // chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
  const seenRels = new Set();
  const pickFresh = (rels) => {
    const fresh = [];
    for (const r of rels) {
      if (seenRels.has(r)) continue;
      seenRels.add(r);
      fresh.push(r);
    }
    return fresh;
  };
  for (const m of msgs) {
    const p = m.payload || {};
    const role = p.role || "?";
    if (role === "system") continue;  // 不显示 system
    if (role === "assistant" && m.model_profile && m.model_profile !== lastAsstModel) {
      const dn = (state.models.find(x => x.profile === m.model_profile) || {}).display_name || m.model_profile;
      const sep = document.createElement("div");
      sep.className = "model-switch muted";
      sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
      sep.textContent = `── ${dn} ──`;
      wrap.appendChild(sep);
      lastAsstModel = m.model_profile;
    }
    if (role === "tool") {
      // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
      const card = document.createElement("div");
      card.className = "msg tool";
      const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
      const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
      const banner = extractMediaBanner(p.name || "", txt || "");
      // 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具
      // (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。
      const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "");
      const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : [];
      card.innerHTML = `
        <div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
        <details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
        ${renderArtifactBarHtml(rels, isProducer)}
      `;
      wrap.appendChild(card);
      continue;
    }
    const card = document.createElement("div");
    card.className = "msg " + role;
    const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
    let html = `<div class="role">${roleLabel}</div>`;
    if (typeof p.content === "string" && p.content) {
      html += `<div class="body">${renderMd(p.content)}</div>`;
      // assistant 正文里 echo 的 <wd>/... 路径**永远**挂 chip(绕开 seenRels —— 上面
      // tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可
      // 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline。
      if (role === "assistant") {
        const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
        html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), false);
      }
    }
    if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
      const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
      for (const tc of p.tool_calls) {
        const fn = (tc.function && tc.function.name) || "?";
        let argsObj = {};
        let args = "";
        try {
          argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}");
          args = JSON.stringify(argsObj, null, 2);
        } catch (e) { args = (tc.function && tc.function.arguments) || ""; }
        const label = toolActivityLabel(fn, argsObj);
        const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
        const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
        html += `
          <details class="tool-call"><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(args)}</pre></details>
          ${renderArtifactBarHtml(rels, isProducer)}
        `;
      }
    }
    card.innerHTML = html;
    highlightIn(card);
    wrap.appendChild(card);
  }
  wrap.scrollTop = wrap.scrollHeight;
  upgradeMediaArtifacts(wrap);
  renderLiveRunIfVisible();
}

// ───── send + SSE ─────
// 发送 / 停止 单按钮:idle → 发送(primary 红实心);streaming → 停止(danger 红边);
// cancelling 是过渡态 — 用户点过停止后到 SSE 收到 cancelled/done 之间。
function setActionMode(mode) {
  const btn = $("chat-action");
  btn.classList.remove("primary", "danger");
  if (mode === "idle") {
    btn.textContent = "发送";
    btn.classList.add("primary");
    btn.disabled = false;
    btn.title = "";
  } else if (mode === "streaming") {
    btn.textContent = "停止";
    btn.classList.add("danger");
    btn.disabled = false;
    btn.title = "停止当前流式回复";
  } else if (mode === "cancelling") {
    btn.textContent = "停止中…";
    btn.classList.add("danger");
    btn.disabled = true;
  }
}

function chatAction() {
  if (isCurrentTaskStreaming()) cancelCurrentTask();
  else sendMessage();
}

$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); chatAction(); });
$("chat-input").addEventListener("keydown", (e) => {
  // streaming 期间 Enter 不触发停止 —— 用户可能正在编辑下一条草稿,误触发风险高
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    if (!isCurrentTaskStreaming()) sendMessage();
  }
});
$("chat-input").addEventListener("input", syncOptimizeBtn);
// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认
// 反馈走 chat-hint:上传中 → 已粘贴 + chip;下一次发送会自然覆盖为"发送中…"。
$("chat-input").addEventListener("paste", async (e) => {
  const files = Array.from(e.clipboardData?.files || []);
  if (!files.length) return;
  e.preventDefault();
  const hint = $("chat-hint");
  const prevHint = hint.textContent;
  hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
  const saved = await uploadFiles(files, {
    onProgress: (loaded, total) => {
      hint.textContent = formatUploadProgress(files, loaded, total);
    },
  });
  if (saved && saved.length) {
    hint.innerHTML = `已粘贴 ${renderPasteFileChips(saved)} <span class="muted">可在右侧文件处查看</span>`;
  } else {
    hint.textContent = prevHint;  // 失败 alert 已弹,hint 回原
  }
});

function renderPasteFileChips(saved) {
  return (saved || []).map((f) => {
    const rel = f.rel || f.name || "";
    const name = f.name || (rel.split("/").pop() || rel);
    return `<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该粘贴文件">×</button></span>`;
  }).join("");
}

$("chat-hint").addEventListener("click", (e) => {
  const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]");
  if (del) {
    e.stopPropagation();
    deletePastedFile(del.dataset.rel, del.closest(".paste-chip-wrap"));
    return;
  }
  const chip = e.target.closest && e.target.closest(".paste-chip[data-rel]");
  if (!chip) return;
  const rel = chip.dataset.rel;
  if (rel) openPasteFilePreview(rel);
});

async function deletePastedFile(rel, wrap) {
  if (!rel || !wrap) return;
  const btn = wrap.querySelector(".paste-chip-del");
  if (btn) btn.disabled = true;
  try {
    await api("POST", "/v1/files/delete", { path: rel, recursive: false });
    closePreviewIfShowing(rel);
    wrap.remove();
    await loadFiles();
    const hint = $("chat-hint");
    if (!hint.querySelector(".paste-chip-wrap")) {
      hint.innerHTML = `<span class="muted">已删除粘贴文件</span>`;
    }
  } catch (e) {
    if (btn) btn.disabled = false;
    if (e.status === 401) { logout(); return; }
    alert("删除失败:" + e.message);
  }
}

// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
// 不与主对话 run 互斥,各跑各的 LLM)。
function syncOptimizeBtn() {
  const btn = $("chat-optimize");
  if (!btn) return;
  if (state.optimizing) return;  // 进行中不在这条路径切
  const has = ($("chat-input").value || "").trim().length > 0;
  btn.disabled = !has || !state.taskId;
}

async function optimizePrompt() {
  if (state.optimizing) return;
  if (!state.taskId) return;
  const ta = $("chat-input");
  const original = (ta.value || "").trim();
  if (!original) return;
  const btn = $("chat-optimize");
  state.optimizing = true;
  btn.disabled = true;
  const oldLabel = btn.textContent;
  btn.textContent = "润色中…";
  const oldHint = $("chat-hint").textContent;
  $("chat-hint").textContent = "润色中…";
  try {
    const r = await api("POST", `/v1/tasks/${state.taskId}/optimize_prompt`, {
      text: ta.value,  // 不 trim — 后端再 strip;保留尾部 newline 让用户感受不变
      image_model: state.imageModel || "",
      video_model: state.videoModel || "",
    });
    const optimized = (r.optimized || "").trim();
    if (!optimized) throw new Error("空结果");
    // execCommand('insertText') 把"全选 + 替换"作为一个 undo 单元接入 textarea 原生栈
    ta.focus();
    ta.select();
    const ok = document.execCommand("insertText", false, optimized);
    if (!ok) {
      // execCommand 在某些环境(contentEditable=false 旧 Firefox)失败 — 兜底直接赋值
      // 这种情况下 Ctrl+Z 失效,但功能不阻塞;贴提示让用户知道
      ta.value = optimized;
      $("chat-hint").textContent = "已润色(本浏览器不支持撤销,需自行保留草稿)";
    } else {
      const cost = typeof r.cost_cny === "number" ? r.cost_cny.toFixed(4) : "?";
      $("chat-hint").textContent = `已润色 · ${r.tokens_in || 0}+${r.tokens_out || 0} tok · ¥${cost} · Ctrl+Z 撤销`;
    }
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    $("chat-hint").textContent = `润色失败:${e.message}`;
  } finally {
    state.optimizing = false;
    btn.textContent = oldLabel;
    syncOptimizeBtn();
  }
}

$("chat-optimize").onclick = optimizePrompt;

// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
// 弹个 modal 反而打断播放,不如交给浏览器。
$("chat-stream").addEventListener("click", (e) => {
  const chip = e.target.closest && e.target.closest(".art-chip");
  if (chip) {
    const rel = chip.dataset.rel;
    if (rel) openFilePreview(rel);
    return;
  }
  const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]");
  if (inlineImg) {
    const rel = inlineImg.dataset.rel;
    if (rel) openFilePreview(rel);
  }
});

async function sendMessage() {
  if (!state.taskId) return;
  if (isCurrentTaskStreaming()) return;
  const content = $("chat-input").value.trim();
  if (!content) return;
  setActionMode("cancelling");  // 临时锁住,等 events_url 拿到再切 streaming
  $("chat-hint").textContent = "发送中…";
  const taskId = state.taskId;
  try {
    // 立刻渲染 user 消息卡(乐观)
    const wrap = $("chat-stream");
    const userCard = document.createElement("div");
    userCard.className = "msg user";
    userCard.innerHTML = `<div class="role">我</div><div class="body">${escapeHtml(content)}</div>`;
    wrap.appendChild(userCard);

    // assistant 流式占位卡
    const asstCard = document.createElement("div");
    asstCard.className = "msg assistant live-run";
    asstCard.innerHTML = `<div class="role">助手</div><div class="body streaming"></div>`;
    wrap.appendChild(asstCard);
    wrap.scrollTop = wrap.scrollHeight;

    const r = await api("POST", `/v1/tasks/${taskId}/messages`, {
      content,
      image_model: state.imageModel || "",
      video_model: state.videoModel || "",
    });
    $("chat-input").value = "";
    syncOptimizeBtn();
    const run = {
      taskId,
      url: r.events_url,
      acc: "",
      pending: false,
      seenRels: new Set(),
      terminal: false,
      card: asstCard,
      body: asstCard.querySelector(".body"),
      cancelling: false,
      workingDir: state.taskMeta && state.taskMeta.working_dir,
    };
    state.liveRuns.set(taskId, run);
    state.streaming = true;
    setActionMode("streaming");
    streamSse(r.events_url, run);
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    appendErrorCard(e.message);
    setActionMode("idle");
    $("chat-hint").textContent = "就绪";
  }
}

async function cancelCurrentTask() {
  const run = getLiveRun(state.taskId);
  if (!state.taskId || !run) return;
  run.cancelling = true;
  setActionMode("cancelling");
  $("chat-hint").textContent = "停止中…";
  try {
    await api("POST", `/v1/tasks/${state.taskId}/cancel`);
    // 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    // 409 = 已结束 / 已 cancelling,不算错;其他贴 toast
    if (e.status !== 409) appendErrorCard("cancel: " + e.message);
    run.cancelling = false;
    setActionMode("streaming");
    $("chat-hint").textContent = "接收中…";
  }
}

function streamSse(url, run) {
  // EventSource 不支持自定义 header,token 走 query string(?token=...)
  // 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
  // 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
  fetchSse(url, run).catch((e) => {
    appendRunError(run, "sse: " + e.message);
  });
}

function appendRunError(run, msg) {
  if (state.taskId === run.taskId) {
    appendErrorCard(msg);
    return;
  }
  const host = run.card || createLiveAssistantCard(run);
  const card = document.createElement("div");
  card.className = "msg error";
  card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
  host.appendChild(card);
}

async function fetchSse(url, run) {
  const body = run.body || (run.card && run.card.querySelector(".body"));
  run.body = body;
  const ctx = run;
  const hint = $("chat-hint");
  // 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。
  // 后端 stream_events 重连入口会校验 run_status,task 已不活跃直接吐 done 关流;
  // 这里 3 次失败再放弃,覆盖 systemctl restart 的 1~2s 抖动 + reaper 跑完的窗口。
  const backoffs = [1000, 2000, 4000];
  let attempt = 0;
  try {
    while (true) {
      try {
        await consumeSseStream(url, run.card, ctx);
      } catch (e) {
        if (ctx.terminal) break;  // 已收到 done/error,不重连
        if (attempt >= backoffs.length) {
          appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。");
          break;
        }
        setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`);
        await new Promise(r => setTimeout(r, backoffs[attempt]));
        attempt++;
        continue;
      }
      // consumeSseStream 正常返回:reader 收到 EOF
      if (ctx.terminal) break;  // 正常收尾(看到 done/error)
      // 未见 done/error 就 EOF → 服务端中途关流(进程被杀 / nginx 切),重连
      if (attempt >= backoffs.length) {
        appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。");
        break;
      }
      setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`);
      await new Promise(r => setTimeout(r, backoffs[attempt]));
      attempt++;
    }
    // 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
    if (ctx.body) {
      ctx.body.innerHTML = renderMd(ctx.acc);
      if (ctx.card) highlightIn(ctx.card);
    }
  } finally {
    if (ctx.body) ctx.body.classList.remove("streaming");
    state.liveRuns.delete(ctx.taskId);
    state.streaming = state.liveRuns.size > 0;
    if (state.taskId === ctx.taskId) {
      hint.textContent = ctx.lastUsageHint || "就绪";
      setActionMode("idle");
    }
  }
  // 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
  loadTaskList();
  if (state.taskId === ctx.taskId) {
    await loadMessages();
    loadFiles();  // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
    refreshConcurrentWarnings();  // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
  }
}

async function consumeSseStream(url, asstCard, ctx) {
  const r = await fetch(url, {
    headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
  });
  if (!r.ok) throw new Error(r.status + " " + r.statusText);
  const reader = r.body.getReader();
  const dec = new TextDecoder();
  let buf = "";
  setRunHint(ctx, "接收中…");
  while (true) {
    const { value, done } = await reader.read();
    if (done) return;
    buf += dec.decode(value, { stream: true });
    while (true) {
      const idx = buf.indexOf("\n\n");
      if (idx < 0) break;
      const frame = buf.slice(0, idx);
      buf = buf.slice(idx + 2);
      const ev = parseSseFrame(frame);
      if (!ev) continue;
      handleSseEvent(ev, asstCard, ctx);
      if (ev.event === "done" || ev.event === "error") {
        ctx.terminal = true;
        return;
      }
    }
  }
}

function parseSseFrame(frame) {
  const lines = frame.split("\n");
  let event = "msg"; let dataLines = [];
  for (const ln of lines) {
    if (ln.startsWith(":")) continue;  // comment
    if (ln.startsWith("event:")) event = ln.slice(6).trim();
    else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, ""));
  }
  if (!dataLines.length) return { event, data: null };
  const raw = dataLines.join("\n");
  let data = null;
  try { data = JSON.parse(raw); } catch (e) { data = raw; }
  return { event, data };
}

function handleSseEvent(ev, asstCard, ctx) {
  const t = ev.event;
  asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx);
  ctx.card = asstCard;
  ctx.body = ctx.body || asstCard.querySelector(".body");
  const stream = $("chat-stream");
  const visible = state.taskId === ctx.taskId;
  // 用户拖到上面看历史时不抢滚动,只在贴底时跟流
  const nearBottom = visible && (stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120);
  if (t === "llm_start") {
    ctx.contextStats = ev.data || {};
    setRunHint(ctx, formatContextStats(ctx.contextStats));
  } else if (t === "llm_end") {
    ctx.lastUsageHint = formatUsageStats(ev.data || {}, ctx.contextStats);
    setRunHint(ctx, ctx.lastUsageHint);
  } else if (t === "text" && ev.data && ev.data.delta) {
    ctx.acc += ev.data.delta;
    // rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
    if (!ctx.pending) {
      ctx.pending = true;
      requestAnimationFrame(() => {
        ctx.body.innerHTML = renderMd(ctx.acc);
        ctx.pending = false;
        if (nearBottom) stream.scrollTop = stream.scrollHeight;
      });
    }
  } else if (t === "tool_call") {
    const fn = (ev.data && ev.data.name) || "?";
    const args = (ev.data && ev.data.args) || "";
    const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
    const label = toolActivityLabel(fn, args);
    const det = document.createElement("details");
    det.className = "tool-call";
    det.innerHTML = `<summary>${escapeHtml(label)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
    asstCard.appendChild(det);
    const wd = _workingDirName(ctx.workingDir);
    const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
    const fresh = isProducer
      ? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r))
      : [];
    fresh.forEach(r => ctx.seenRels.add(r));
    const barHtml = renderArtifactBarHtml(fresh, isProducer);
    if (barHtml) {
      asstCard.insertAdjacentHTML("beforeend", barHtml);
      if (isProducer) upgradeMediaArtifacts(asstCard);
    }
  } else if (t === "tool_result") {
    const txt = (ev.data && ev.data.result) || "";
    const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2);
    const toolName = (ev.data && ev.data.name) || "";
    const banner = extractMediaBanner(toolName, txtStr);
    const det = document.createElement("details");
    det.className = "tool-call";
    det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
    asstCard.appendChild(det);
    const wd = _workingDirName(ctx.workingDir);
    const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName);
    const fresh = isProducer
      ? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r))
      : [];
    fresh.forEach(r => ctx.seenRels.add(r));
    const barHtml = renderArtifactBarHtml(fresh, isProducer);
    if (barHtml) {
      asstCard.insertAdjacentHTML("beforeend", barHtml);
      if (isProducer) upgradeMediaArtifacts(asstCard);
    }
    if (visible) scheduleFilesRefresh();  // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
  } else if (t === "cancelled") {
    const badge = document.createElement("div");
    badge.className = "cancelled-badge";
    badge.textContent = "已停止";
    asstCard.appendChild(badge);
  } else if (t === "error") {
    const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
    appendRunError(ctx, msg);
  }
  if (nearBottom) stream.scrollTop = stream.scrollHeight;
}

function appendErrorCard(msg) {
  const card = document.createElement("div");
  card.className = "msg error";
  card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
  $("chat-stream").appendChild(card);
  $("chat-stream").scrollTop = $("chat-stream").scrollHeight;
}

// ───── done / abandon / delete / export ─────
$("btn-done").onclick = () => state.taskId && setTaskStatus(state.taskId, "completed", (state.taskMeta && state.taskMeta.name) || "");
$("btn-abandon").onclick = () => state.taskId && setTaskStatus(state.taskId, "abandoned", (state.taskMeta && state.taskMeta.name) || "");
$("btn-delete-task").onclick = () => {
  if (!state.taskId) return;
  const t = state.taskMeta || {};
  deleteTask(state.taskId, t.name || "(未命名)", t.n_messages || 0);
};
$("btn-export").onclick = () => state.taskId && exportTask(state.taskId);
$("btn-clear-msgs").onclick = () => {
  if (!state.taskId) return;
  const t = state.taskMeta || {};
  clearMessages(state.taskId, t.name || "(未命名)", t.n_messages || 0);
};

async function clearMessages(tid, name, nMsg) {
  if (!confirm(`确认清空「${name}」的对话(${nMsg} 条消息)?\n\n将删除全部对话历史并重置 token 计数;工作目录下的文件保留。`)) return;
  try {
    const updated = await api("POST", "/v1/tasks/" + tid + "/clear");
    if (state.taskId === tid) {
      state.taskMeta = updated;
      renderChatMeta();
      renderMessages([]);
      $("chat-hint").textContent = "对话已清空";
    }
    loadTaskList();
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    alert("清空失败:" + e.message);
  }
}

async function setTaskStatus(tid, status, name) {
  const labels = { completed: "已完成", abandoned: "已废弃" };
  if (!confirm(`确认将「${name || tid.slice(0,8)}」置为「${labels[status] || status}」?`)) return;
  try {
    await api("PATCH", "/v1/tasks/" + tid, { status });
    if (state.taskId === tid) await selectTask(tid);
    loadTaskList();
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    alert("操作失败:" + e.message);
  }
}

async function deleteTask(tid, name, nMsg) {
  if (!confirm(`确认硬删除任务「${name}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
  try {
    await api("DELETE", "/v1/tasks/" + tid);
    if (state.taskId === tid) {
      if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
      state.taskId = null;
      state.taskMeta = null;
      state.concurrentWarnings = [];
      renderConcurrentWarning();
      $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
      $("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
      $("chat-form").style.display = "none";
      $("btn-done").disabled = true;
      $("btn-abandon").disabled = true;
      $("btn-delete-task").disabled = true;
      $("btn-export").disabled = true;
      $("btn-clear-msgs").disabled = true;
    }
    loadTaskList();
    loadFiles();
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    alert("删除失败:" + e.message);
  }
}

function exportTask(tid) {
  fetch("/v1/tasks/" + tid + "/export", {
    headers: { "Authorization": "Bearer " + state.token },
  }).then(async (r) => {
    if (!r.ok) { alert("导出失败:" + r.status); return; }
    const blob = await r.blob();
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = "chat_" + tid.slice(0, 8) + ".docx";
    document.body.appendChild(a); a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
  });
}
