279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
// 新建任务弹框:可选任务名 / 可搜索工作目录组合框(复用“开始一段新对话”交互)/
|
||
// 描述 / 智能体(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();
|
||
}
|
||
};
|
||
|