zcbot/web/static/js/newtask.js

279 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 新建任务弹框:可选任务名 / 可搜索工作目录组合框(复用“开始一段新对话”交互)/
// 描述 / 智能体(skill)/ 模型 select,提交 POST /v1/tasks。
// 顶层自绑 hd-new-custom 打开、nt-go 提交、各 input 联动;唯一对外导出 loadFolderSuggestions
//(供 main enterApp 初始化顶部 filter-wd、files 新建/复制/移动后刷新目录列表)。
import { $ } from "./dom.js";
import { state } from "./state.js";
import { api } from "./api.js";
import { escapeHtml } from "./format.js";
import { logout } from "./auth.js";
import { loadModels, loadTaskList, selectTask } from "./chat.js";
// ───── new task ─────
const LS_RECENT_WORKING_DIR = "zcbot.recent-working-dir";
let customWorkingDir = "";
let customDirNewMode = false;
let customDirActiveIndex = -1;
$("hd-new-custom").onclick = async () => {
$("nt-name").value = "";
customWorkingDir = "";
customDirNewMode = false;
$("nt-wd-picker").value = "";
$("nt-wd-picker").dataset.searching = "";
setCustomFolderOpen(false);
$("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = "";
$("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
renderCustomFolderOptions();
syncCustomDirHint();
populateModelSelect();
$("nt-name").focus();
};
function populateModelSelect() {
const sel = $("nt-model");
const models = state.models || [];
if (models.length === 0) {
sel.innerHTML = `<option value="">(默认)</option>`;
return;
}
sel.innerHTML = models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.is_default ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
}
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => {
const name = $("nt-name").value.trim();
const working_dir = currentCustomWorkingDir();
const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value;
const model_profile = $("nt-model").value;
$("nt-err").textContent = "";
if (!working_dir) {
$("nt-err").textContent = "请选择已有工作目录或创建新目录";
return;
}
try {
const body = { working_dir, description: desc, skill, model_profile };
if (name) body.name = name;
const t = await api("POST", "/v1/tasks", body);
localStorage.setItem(LS_RECENT_WORKING_DIR, working_dir);
$("new-task-modal").classList.remove("show");
await loadTaskList();
selectTask(t.task_id);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("nt-err").textContent = e.message;
}
};
// 工作目录:拉数据 + 灌顶部 filter-wd并刷新自定义创建组合框。
export async function loadFolderSuggestions() {
try {
const data = await api("GET", "/v1/folders");
state.folders = data.folders || [];
} catch (e) {
state.folders = state.folders || [];
}
populateFolderSelects();
renderCustomFolderOptions();
}
// 灌顶部 filter-wd自定义创建弹框使用与快速新对话一致的组合框。
function populateFolderSelects() {
const folders = state.folders || [];
// 顶部 filter:第一项 "(全部目录)" sentinel
const filterSel = $("filter-wd");
const filterCur = filterSel.value;
const filterOpts = ['<option value="">(全部目录)</option>'];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
filterOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${escapeHtml(tag)}</option>`);
}
filterSel.innerHTML = filterOpts.join("");
filterSel.value = filterCur; // 重渲后恢复选中
}
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
async function loadSkillOptions() {
const sel = $("nt-skill");
if (!state.skills) {
try {
const data = await api("GET", "/v1/skills");
state.skills = data.skills || [];
} catch (e) {
state.skills = []; // 静默兜底,select 仍保留"(默认)"项
}
}
// 渲染:第一项固定为"默认"(空 value),其后逐 skill 一项
const opts = ['<option value="">(默认 · 不限定)</option>'];
for (const s of state.skills) {
const label = `${s.name}${s.description ? " — " + s.description : ""}`;
opts.push(`<option value="${escapeHtml(s.name)}" title="${escapeHtml(s.description || "")}">${escapeHtml(label)}</option>`);
}
sel.innerHTML = opts.join("");
sel.value = ""; // hd-new 已清空,这里幂等再保一次
}
// === 自定义创建目录组合框(与“开始一段新对话”一致)===
// UI 新契约:工作目录始终必填,任务名选填;不再用任务名自动补目录。
function currentCustomWorkingDir() {
return customWorkingDir.trim();
}
function customDirIsNew() {
return !!currentCustomWorkingDir() && customDirNewMode;
}
function syncCustomDirHint() {
const target = currentCustomWorkingDir();
$("nt-wd-hint").textContent = target
? customDirIsNew()
? `将新建工作目录「${target}`
: `文件将保存到「${target}`
: "可选择已有目录,也可以新建目录。";
}
function renderCustomFolderOptions() {
const input = $("nt-wd-picker");
const list = $("nt-wd-options");
if (!input || !list) return;
const query = input.dataset.searching === "1"
? input.value.trim().toLocaleLowerCase()
: "";
const recent = localStorage.getItem(LS_RECENT_WORKING_DIR) || "";
const folders = [...(state.folders || [])];
folders.sort((a, b) => Number(b.name === recent) - Number(a.name === recent));
const visible = query
? folders.filter(f => f.name.toLocaleLowerCase().includes(query))
: folders;
const exact = query
? folders.some(f => f.name.toLocaleLowerCase() === query)
: false;
const selected = customDirIsNew() ? "" : currentCustomWorkingDir();
const existingHtml = visible.map((f, i) => {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : "空目录";
const recentTag = f.name === recent
? `<span class="new-chat-dir-recent">最近使用</span>`
: "";
const chosen = f.name === selected ? " selected" : "";
return `<button type="button" id="nt-wd-option-${i}"
class="new-chat-dir-option${chosen}" role="option"
aria-selected="${f.name === selected}"
data-name="${escapeHtml(f.name)}">
<span>${escapeHtml(f.name)}${recentTag}</span>
<small>${tag}</small>
</button>`;
}).join("");
const createHtml = query && !exact
? `<button type="button" id="nt-wd-create"
class="new-chat-dir-option new-chat-dir-create" role="option"
aria-selected="false" data-create="${escapeHtml(input.value.trim())}">
<span>创建工作目录「${escapeHtml(input.value.trim())}」</span>
</button>`
: `<div class="new-chat-dir-create-hint">输入新名称可创建工作目录</div>`;
const emptyHtml = !visible.length && !query
? `<div class="new-chat-dir-empty">暂无已有目录</div>`
: "";
list.innerHTML = existingHtml + emptyHtml + createHtml;
if (input.dataset.searching !== "1") input.value = currentCustomWorkingDir();
customDirActiveIndex = -1;
input.setAttribute("aria-activedescendant", "");
list.querySelectorAll(".new-chat-dir-option").forEach(option => {
option.onmousedown = e => e.preventDefault();
option.onclick = () => selectCustomFolder(
option.dataset.create || option.dataset.name || "",
!!option.dataset.create,
);
});
}
function setCustomFolderOpen(open) {
const combo = $("nt-wd-combobox");
const input = $("nt-wd-picker");
combo.classList.toggle("open", open);
input.setAttribute("aria-expanded", String(open));
if (!open) {
input.dataset.searching = "";
input.value = currentCustomWorkingDir();
customDirActiveIndex = -1;
input.setAttribute("aria-activedescendant", "");
}
}
function selectCustomFolder(name, isNew) {
const trimmed = name.trim();
if (!trimmed) return;
customWorkingDir = trimmed;
customDirNewMode = isNew;
setCustomFolderOpen(false);
syncCustomDirHint();
renderCustomFolderOptions();
}
function moveCustomFolderActive(delta) {
const input = $("nt-wd-picker");
const options = [...document.querySelectorAll("#nt-wd-options .new-chat-dir-option")];
if (!options.length) return;
customDirActiveIndex = (
customDirActiveIndex + delta + options.length
) % options.length;
options.forEach((option, i) => option.classList.toggle("active", i === customDirActiveIndex));
const active = options[customDirActiveIndex];
input.setAttribute("aria-activedescendant", active.id);
active.scrollIntoView({ block: "nearest" });
}
const customPicker = $("nt-wd-picker");
const customCombo = $("nt-wd-combobox");
customPicker.onfocus = () => {
customPicker.dataset.searching = "";
setCustomFolderOpen(true);
renderCustomFolderOptions();
customPicker.select();
};
customPicker.oninput = () => {
customPicker.dataset.searching = "1";
setCustomFolderOpen(true);
renderCustomFolderOptions();
};
customPicker.onkeydown = e => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setCustomFolderOpen(true);
moveCustomFolderActive(e.key === "ArrowDown" ? 1 : -1);
} else if (e.key === "Enter" && customCombo.classList.contains("open")) {
const options = [...document.querySelectorAll("#nt-wd-options .new-chat-dir-option")];
const active = options[customDirActiveIndex];
const typed = customPicker.value.trim().toLocaleLowerCase();
const exact = options.find(
o => (o.dataset.name || "").toLocaleLowerCase() === typed,
);
const target = active || exact || (options.length === 1 ? options[0] : null);
if (target) {
e.preventDefault();
selectCustomFolder(
target.dataset.create || target.dataset.name || "",
!!target.dataset.create,
);
}
} else if (e.key === "Escape") {
e.preventDefault();
setCustomFolderOpen(false);
customPicker.blur();
}
};
customPicker.onblur = () => setTimeout(() => setCustomFolderOpen(false), 0);
$("nt-wd-toggle").onmousedown = e => e.preventDefault();
$("nt-wd-toggle").onclick = () => {
const open = !customCombo.classList.contains("open");
setCustomFolderOpen(open);
if (open) {
customPicker.focus();
customPicker.select();
renderCustomFolderOptions();
}
};