@co0ontty/wand 4.58.0 → 4.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,7 @@ Common options:
95
95
  | `https` | `false` | Enable HTTPS with an auto-generated self-signed certificate |
96
96
  | `password` | random | Login password |
97
97
  | `language` | `""` | Preferred Claude response language |
98
+ | `publicOrigin` | unset | Public URL clients should use, e.g. `https://home.example.com:8443`. Required when TLS terminates in an L4 proxy |
98
99
 
99
100
  ### System Service
100
101
 
@@ -262,6 +263,7 @@ wand config:set port 9443
262
263
  | `https` | `false` | 启用 HTTPS(自签证书自动生成) |
263
264
  | `password` | (随机生成) | 登录密码 |
264
265
  | `language` | `""` | Claude 回复语言偏好 |
266
+ | `publicOrigin` | 未设置 | 客户端应使用的公开访问地址,如 `https://home.example.com:8443`。TLS 在 L4 反代终止时必填 |
265
267
 
266
268
  ### 系统服务
267
269
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "3ab96a356a555f419adb4ec4c5cba63dfd170a5c",
3
- "builtAt": "2026-09-11T13:51:01.381Z",
4
- "version": "4.58.0",
2
+ "commit": "18846ddec22bf00f6ee5245b09d7266ab42a1056",
3
+ "builtAt": "2026-09-12T02:12:46.324Z",
4
+ "version": "4.59.0",
5
5
  "channel": "stable"
6
6
  }
package/dist/cli.js CHANGED
@@ -761,6 +761,27 @@ function setConfigValue(config, key, value) {
761
761
  ...config,
762
762
  https: value === "true"
763
763
  };
764
+ case "publicOrigin": {
765
+ if (!value)
766
+ throw new Error("publicOrigin must not be empty; use 'wand config:unset publicOrigin' to clear it");
767
+ let parsed;
768
+ try {
769
+ parsed = new URL(value);
770
+ }
771
+ catch {
772
+ throw new Error("publicOrigin must be an absolute URL, e.g. https://home.example.com:8443");
773
+ }
774
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
775
+ throw new Error("publicOrigin must use http:// or https://");
776
+ }
777
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
778
+ throw new Error("publicOrigin must be a bare origin without credentials, path, query, or fragment");
779
+ }
780
+ return {
781
+ ...config,
782
+ publicOrigin: parsed.origin
783
+ };
784
+ }
764
785
  default:
765
786
  throw new Error(`Unsupported config key: ${key}`);
766
787
  }
package/dist/config.js CHANGED
@@ -683,6 +683,23 @@ function mergeWithDefaults(input) {
683
683
  ...input,
684
684
  // Ensure https is boolean
685
685
  https: typeof input.https === "boolean" ? input.https : defaults.https,
686
+ // 只接受不带路径/查询/凭据的 http(s) origin;写了非法值就当作没配。
687
+ publicOrigin: (() => {
688
+ const raw = typeof input.publicOrigin === "string" ? input.publicOrigin.trim() : "";
689
+ if (!raw)
690
+ return undefined;
691
+ try {
692
+ const parsed = new URL(raw);
693
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
694
+ return undefined;
695
+ if (!parsed.hostname || parsed.username || parsed.password)
696
+ return undefined;
697
+ return parsed.origin;
698
+ }
699
+ catch {
700
+ return undefined;
701
+ }
702
+ })(),
686
703
  tls: (() => {
687
704
  if (!input.tls || typeof input.tls !== "object")
688
705
  return undefined;
@@ -1,16 +1,24 @@
1
1
  import type { Express, RequestHandler } from "express";
2
2
  import { type WandStorage } from "./storage.js";
3
+ import { generateWandTaskTitle } from "./task-title.js";
3
4
  import type { SessionRegistry } from "./session-registry.js";
4
5
  import type { StructuredSessionManager } from "./structured-session-manager.js";
5
6
  import type { WandTaskAgent } from "./task-types.js";
6
7
  import type { WandConfig } from "./types.js";
8
+ /** 任务面板上次选用的 CLI 工具 / 模型 / 思考深度;从未保存过时回落到 Claude 默认。 */
9
+ export declare function readTaskBoardLastAgent(storage: WandStorage): WandTaskAgent;
10
+ export declare function writeTaskBoardLastAgent(storage: WandStorage, agent: WandTaskAgent): void;
7
11
  export interface TaskRouteDependencies {
8
12
  storage: WandStorage;
9
13
  sessions?: SessionRegistry;
10
14
  structured?: StructuredSessionManager;
11
15
  config?: WandConfig;
12
16
  requireAdmin?: RequestHandler;
17
+ /** 可注入的任务标题生成器;测试里换成同步桩,避免真的起 CLI。 */
18
+ generateTitle?: typeof generateWandTaskTitle;
13
19
  }
14
20
  /** 任务派发配置:provider / model / thinkingEffort 三者必须同时给出且合法。 */
15
21
  export declare function parseTaskAgent(value: unknown): WandTaskAgent | null;
22
+ /** 等待排队中的标题生成全部结束;只有测试用,避免用例轮询。 */
23
+ export declare function whenWandTaskTitlesSettled(): Promise<void>;
16
24
  export declare function registerTaskRoutes(app: Express, deps: TaskRouteDependencies | WandStorage, sessionsLegacy?: SessionRegistry, requireAdminLegacy?: RequestHandler): void;
@@ -2,11 +2,36 @@ import { getDefaultModelForProvider } from "./config.js";
2
2
  import { asyncRoute } from "./express-async.js";
3
3
  import { getErrorMessage } from "./error-utils.js";
4
4
  import { parseWandTaskAgent } from "./storage.js";
5
- import { archiveBoardTask } from "./wand-task-sync.js";
5
+ import { generateWandTaskTitle, provisionalTaskTitleFromDescription, TASK_TITLE_MAX_LENGTH } from "./task-title.js";
6
+ import { resolveSystemAiContext } from "./session-ai-context.js";
7
+ import { archiveBoardTask, syncUngroupedSessionsToBoard } from "./wand-task-sync.js";
6
8
  const STATUSES = new Set(["todo", "doing", "done"]);
7
9
  const PRIORITIES = new Set(["none", "low", "medium", "high", "urgent"]);
8
10
  const AGENT_PROVIDERS = new Set(["claude", "codex", "opencode", "grok", "qoder", "pi"]);
9
11
  const AGENT_EFFORTS = new Set(["off", "standard", "deep", "max"]);
12
+ const TASK_BOARD_LAST_AGENT_KEY = "pref:taskBoardLastAgent";
13
+ function defaultTaskBoardAgent() {
14
+ return { provider: "claude", model: "default", thinkingEffort: "off" };
15
+ }
16
+ /** 任务面板上次选用的 CLI 工具 / 模型 / 思考深度;从未保存过时回落到 Claude 默认。 */
17
+ export function readTaskBoardLastAgent(storage) {
18
+ const raw = storage.getPreference(TASK_BOARD_LAST_AGENT_KEY, null);
19
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
20
+ return defaultTaskBoardAgent();
21
+ try {
22
+ return parseTaskAgent(raw) ?? defaultTaskBoardAgent();
23
+ }
24
+ catch {
25
+ return defaultTaskBoardAgent();
26
+ }
27
+ }
28
+ export function writeTaskBoardLastAgent(storage, agent) {
29
+ storage.setPreference(TASK_BOARD_LAST_AGENT_KEY, {
30
+ provider: agent.provider,
31
+ model: agent.model,
32
+ thinkingEffort: agent.thinkingEffort,
33
+ });
34
+ }
10
35
  function dateValue(value) {
11
36
  if (value === undefined)
12
37
  return undefined;
@@ -80,6 +105,52 @@ function taskSessionSummary(session) {
80
105
  function sendTaskError(res, error, fallback) {
81
106
  res.status(400).json({ error: getErrorMessage(error, fallback) });
82
107
  }
108
+ /**
109
+ * 用户没写标题时,先用描述首行当占位标题,再由后台模型总结一个更好的。
110
+ * 全程不阻塞创建请求:失败只是保留占位标题,不返回错误、也不覆盖用户后来改的标题。
111
+ * 多次建任务会排队而不是丢弃,保证每张卡片最终都会拿到标题。
112
+ */
113
+ let titleGenerationChain = Promise.resolve();
114
+ function scheduleWandTaskTitleGeneration(storage, taskId, description, options) {
115
+ const cwd = options.cwd || options.config?.defaultCwd || process.cwd();
116
+ const run = async () => {
117
+ try {
118
+ const title = await options.generateTitle(description, cwd, options.config?.language ?? "", taskTitleAiOptions(options.config));
119
+ const current = storage.getWandTask(taskId);
120
+ // 用户已经自己写了标题(原生端 / 面板编辑)就不要覆盖。
121
+ if (!current || current.titleSource !== "auto")
122
+ return;
123
+ const clipped = title.slice(0, TASK_TITLE_MAX_LENGTH);
124
+ if (!clipped || clipped === current.title)
125
+ return;
126
+ storage.updateWandTask(taskId, { title: clipped });
127
+ }
128
+ catch (error) {
129
+ console.error(`[WandTask] Failed to auto-generate title for ${taskId}:`, getErrorMessage(error));
130
+ }
131
+ };
132
+ titleGenerationChain = titleGenerationChain.then(run, run);
133
+ }
134
+ /** 等待排队中的标题生成全部结束;只有测试用,避免用例轮询。 */
135
+ export function whenWandTaskTitlesSettled() {
136
+ return titleGenerationChain;
137
+ }
138
+ /** 自动生成标题没有会话上下文,按「默认 provider + 默认模型」解析,与提示词优化一致。 */
139
+ function taskTitleAiOptions(config) {
140
+ if (!config)
141
+ return {};
142
+ const provider = config.defaultProvider ?? "claude";
143
+ const defaultSession = {
144
+ provider,
145
+ structuredState: undefined,
146
+ runner: undefined,
147
+ command: provider === "qoder" ? "qodercli" : provider,
148
+ selectedModel: null,
149
+ thinkingEffort: config.defaultThinkingEffort,
150
+ };
151
+ // resolveSystemAiContext 会在直连 API 可用且已启用时优先走 API,否则回落到 CLI。
152
+ return resolveSystemAiContext(defaultSession, config);
153
+ }
83
154
  export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy) {
84
155
  // 兼容旧的 positional 调用;新代码统一用对象形式的依赖。
85
156
  const resolved = deps && typeof deps === "object" && "storage" in deps
@@ -89,31 +160,66 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
89
160
  const guard = resolved.requireAdmin ?? ((_req, _res, next) => next());
90
161
  const dto = (task) => taskDto(resolved, task);
91
162
  app.get("/api/wand-tasks", guard, (req, res) => {
163
+ try {
164
+ syncUngroupedSessionsToBoard(storage);
165
+ }
166
+ catch (error) {
167
+ console.error("[WandTask] Failed to sync ungrouped sessions onto the board:", getErrorMessage(error));
168
+ }
92
169
  const workspaceId = typeof req.query.workspaceId === "string" ? req.query.workspaceId : undefined;
93
170
  res.json(storage.listWandTasks(workspaceId).map((task) => dto(task)));
94
171
  });
172
+ app.get("/api/wand-task-agent-defaults", guard, (_req, res) => {
173
+ res.json(readTaskBoardLastAgent(storage));
174
+ });
175
+ app.put("/api/wand-task-agent-defaults", guard, (req, res) => {
176
+ try {
177
+ const agent = parseTaskAgent(req.body);
178
+ if (!agent)
179
+ throw new Error("请选择有效的 CLI 工具。");
180
+ writeTaskBoardLastAgent(storage, agent);
181
+ res.json(agent);
182
+ }
183
+ catch (error) {
184
+ sendTaskError(res, error, "无法保存 Agent 默认选项。");
185
+ }
186
+ });
95
187
  app.post("/api/wand-tasks", guard, (req, res) => {
96
188
  try {
97
189
  const body = bodyObject(req.body);
98
- const title = text(body.title);
99
- if (!title)
100
- throw new Error("任务标题不能为空。");
101
190
  const workspaceId = body.workspaceId === null ? null : text(body.workspaceId) || null;
102
191
  if (workspaceId && !storage.getWorkspace(workspaceId))
103
192
  throw new Error("项目不存在。");
193
+ const description = text(body.description);
194
+ // 标题是可选字段:用户不写就先用描述首行占位,再由模型在后台覆盖。
195
+ const providedTitle = text(body.title);
196
+ if (!providedTitle && !description)
197
+ throw new Error("请填写任务标题或任务描述。");
198
+ const titleSource = providedTitle ? "user" : "auto";
199
+ const title = providedTitle || provisionalTaskTitleFromDescription(description) || "新建任务";
104
200
  const status = STATUSES.has(body.status) ? body.status : "todo";
105
201
  const priority = PRIORITIES.has(body.priority) ? body.priority : "none";
106
202
  const agent = body.agent === undefined ? null : parseTaskAgent(body.agent);
203
+ if (agent)
204
+ writeTaskBoardLastAgent(storage, agent);
107
205
  const task = storage.createWandTask({
108
206
  workspaceId,
109
207
  title,
110
- description: text(body.description),
208
+ titleSource,
209
+ description,
111
210
  status,
112
211
  priority,
113
212
  labels: labelsFrom(body.labels),
114
213
  dueDate: dateValue(body.dueDate) ?? null,
115
214
  agent,
116
215
  });
216
+ if (titleSource === "auto") {
217
+ scheduleWandTaskTitleGeneration(storage, task.id, description, {
218
+ cwd: workspaceId ? storage.getWorkspace(workspaceId)?.cwd : undefined,
219
+ config,
220
+ generateTitle: resolved.generateTitle ?? generateWandTaskTitle,
221
+ });
222
+ }
117
223
  res.status(201).json(dto(task));
118
224
  }
119
225
  catch (error) {
@@ -125,10 +231,12 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
125
231
  const body = bodyObject(req.body);
126
232
  const patch = {};
127
233
  if (body.title !== undefined) {
234
+ // 显式改标题(包括原生端的可编辑标题框)都算用户自己写的。
128
235
  const value = text(body.title);
129
236
  if (!value)
130
237
  throw new Error("任务标题不能为空。");
131
238
  patch.title = value;
239
+ patch.titleSource = "user";
132
240
  }
133
241
  if (body.description !== undefined)
134
242
  patch.description = text(body.description);
@@ -152,8 +260,11 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
152
260
  throw new Error("项目不存在。");
153
261
  patch.workspaceId = workspaceId || null;
154
262
  }
155
- if (body.agent !== undefined)
263
+ if (body.agent !== undefined) {
156
264
  patch.agent = parseTaskAgent(body.agent);
265
+ if (patch.agent)
266
+ writeTaskBoardLastAgent(storage, patch.agent);
267
+ }
157
268
  if (body.sortOrder !== undefined && Number.isFinite(Number(body.sortOrder))) {
158
269
  patch.sortOrder = Math.floor(Number(body.sortOrder));
159
270
  }
@@ -215,6 +326,7 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
215
326
  : parseTaskAgent(body.agent);
216
327
  if (!agent)
217
328
  throw new Error("请先为该任务选择 CLI 工具。");
329
+ writeTaskBoardLastAgent(storage, agent);
218
330
  const workspace = task.workspaceId ? storage.getWorkspace(task.workspaceId) : null;
219
331
  if (task.workspaceId && !workspace)
220
332
  throw new Error("任务所属项目已被删除,请重新指定。");
@@ -230,6 +342,7 @@ export function registerTaskRoutes(app, deps, sessionsLegacy, requireAdminLegacy
230
342
  sessionSource: "automation",
231
343
  automationId: `wand-task:${task.id}`,
232
344
  workspaceId: task.workspaceId ?? undefined,
345
+ workspaceTaskId: task.workspaceTaskId ?? undefined,
233
346
  });
234
347
  storage.updateWandTask(task.id, { agent, status: task.status === "todo" ? "doing" : task.status });
235
348
  storage.bindWandTaskSession(task.id, session.id);
package/dist/server.js CHANGED
@@ -260,6 +260,11 @@ function authenticateBearerAppToken(req, storage, config) {
260
260
  }
261
261
  }
262
262
  function resolveRequestServerUrl(req, config, useHttps) {
263
+ // 配置了公开 origin 就优先用它:TLS 在 L4 反代(nginx stream / NPM TCP stream)终止时
264
+ // node 只看到明文 HTTP,既没有 X-Forwarded-Proto 也没有正确的 scheme,猜不出来。
265
+ const configuredOrigin = normalizePublicOrigin(config.publicOrigin);
266
+ if (configuredOrigin)
267
+ return resolveAppConnectOrigin(configuredOrigin, config);
263
268
  const requestProtocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
264
269
  const requestHost = getPublicRequestHost(req, config);
265
270
  const originHeader = firstHeaderValue(req.headers.origin);
@@ -960,10 +965,11 @@ export async function startServer(config, configPath, options = {}) {
960
965
  modelCatalog,
961
966
  resolveAppConnectCode: (req) => {
962
967
  const effectivePassword = getEffectivePassword(storage, config);
968
+ const configuredOrigin = normalizePublicOrigin(config.publicOrigin);
963
969
  const requestProtocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
964
970
  const requestHost = getPublicRequestHost(req, config);
965
971
  const browserOrigin = normalizePublicOrigin(firstQueryStringValue(req.query.origin));
966
- const serverUrl = resolveAppConnectOrigin(browserOrigin ?? `${requestProtocol}://${requestHost}`, config);
972
+ const serverUrl = resolveAppConnectOrigin(configuredOrigin ?? browserOrigin ?? `${requestProtocol}://${requestHost}`, config);
967
973
  const token = generateAppToken(effectivePassword, config.appSecret ?? "");
968
974
  return { code: encodeConnectCode(serverUrl, token), url: serverUrl };
969
975
  },
package/dist/storage.d.ts CHANGED
@@ -121,6 +121,7 @@ export declare class WandStorage {
121
121
  workspaceId?: string | null;
122
122
  workspaceTaskId?: string | null;
123
123
  title: string;
124
+ titleSource?: import("./task-types.js").WandTaskTitleSource;
124
125
  description?: string;
125
126
  status?: import("./task-types.js").WandTaskStatus;
126
127
  priority?: import("./task-types.js").WandTaskPriority;
@@ -128,9 +129,10 @@ export declare class WandStorage {
128
129
  dueDate?: string | null;
129
130
  agent?: import("./task-types.js").WandTaskAgent | null;
130
131
  }): import("./task-types.js").WandTask;
131
- updateWandTask(id: string, patch: Partial<Pick<import("./task-types.js").WandTask, "workspaceId" | "workspaceTaskId" | "title" | "description" | "status" | "priority" | "labels" | "dueDate" | "sortOrder" | "agent">>): import("./task-types.js").WandTask | null;
132
+ updateWandTask(id: string, patch: Partial<Pick<import("./task-types.js").WandTask, "workspaceId" | "workspaceTaskId" | "title" | "titleSource" | "description" | "status" | "priority" | "labels" | "dueDate" | "sortOrder" | "agent">>): import("./task-types.js").WandTask | null;
132
133
  deleteWandTask(id: string): void;
133
134
  listWandTaskSessionIds(taskId: string): string[];
135
+ listBoundWandTaskSessionIds(): string[];
134
136
  bindWandTaskSession(taskId: string, sessionId: string): void;
135
137
  unbindWandTaskSession(taskId: string, sessionId: string): void;
136
138
  listGithubIssueBindings(owner: string, repo: string, issueNumber: number): Array<{
package/dist/storage.js CHANGED
@@ -719,6 +719,7 @@ const INIT_SQL = `
719
719
  sort_order INTEGER NOT NULL DEFAULT 0,
720
720
  agent_json TEXT,
721
721
  workspace_task_id TEXT,
722
+ title_source TEXT,
722
723
  created_at TEXT NOT NULL,
723
724
  updated_at TEXT NOT NULL,
724
725
  FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL
@@ -746,6 +747,9 @@ function ensureWandTaskSchema(db) {
746
747
  db.exec("ALTER TABLE wand_tasks ADD COLUMN agent_json TEXT");
747
748
  if (columns.length > 0 && !names.has("workspace_task_id"))
748
749
  db.exec("ALTER TABLE wand_tasks ADD COLUMN workspace_task_id TEXT");
750
+ // 标题来源:'user' = 用户手写,'auto' = 由描述自动生成。只加列,历史行保持 NULL(读取时视为 user)。
751
+ if (columns.length > 0 && !names.has("title_source"))
752
+ db.exec("ALTER TABLE wand_tasks ADD COLUMN title_source TEXT");
749
753
  if (columns.length > 0) {
750
754
  // Index creation must wait until the column exists on legacy databases.
751
755
  // INIT_SQL cannot create it: CREATE TABLE IF NOT EXISTS is a no-op on old
@@ -1152,7 +1156,7 @@ export class WandStorage {
1152
1156
  });
1153
1157
  }
1154
1158
  listWandTasks(workspaceId) {
1155
- const rows = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1159
+ const rows = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1156
1160
  FROM wand_tasks ${workspaceId === undefined ? "" : "WHERE workspace_id IS ?"}
1157
1161
  ORDER BY status, sort_order, updated_at DESC`).all(...(workspaceId === undefined ? [] : [workspaceId]));
1158
1162
  return rows.map((row) => this.mapWandTaskRow(row));
@@ -1164,6 +1168,7 @@ export class WandStorage {
1164
1168
  workspaceId: typeof row.workspace_id === "string" ? row.workspace_id : null,
1165
1169
  workspaceTaskId: typeof row.workspace_task_id === "string" && row.workspace_task_id ? row.workspace_task_id : null,
1166
1170
  title: String(row.title),
1171
+ titleSource: row.title_source === "auto" ? "auto" : "user",
1167
1172
  description: String(row.description ?? ""),
1168
1173
  status: row.status === "doing" || row.status === "done" ? row.status : "todo",
1169
1174
  priority: row.priority === "low" || row.priority === "medium" || row.priority === "high" || row.priority === "urgent" ? row.priority : "none",
@@ -1179,12 +1184,12 @@ export class WandStorage {
1179
1184
  return this.listWandTasks().find((task) => task.id === id) ?? null;
1180
1185
  }
1181
1186
  getWandTaskByWorkspaceTaskId(workspaceTaskId) {
1182
- const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1187
+ const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1183
1188
  FROM wand_tasks WHERE workspace_task_id = ? ORDER BY updated_at DESC LIMIT 1`).get(workspaceTaskId);
1184
1189
  return row ? this.mapWandTaskRow(row) : null;
1185
1190
  }
1186
1191
  findUnlinkedWandTask(workspaceId, title) {
1187
- const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1192
+ const row = this.db.prepare(`SELECT id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at
1188
1193
  FROM wand_tasks
1189
1194
  WHERE workspace_id IS ? AND title = ? AND (workspace_task_id IS NULL OR workspace_task_id = '')
1190
1195
  ORDER BY updated_at DESC LIMIT 1`).get(workspaceId, title);
@@ -1198,7 +1203,7 @@ export class WandStorage {
1198
1203
  const max = this.db.prepare("SELECT COALESCE(MAX(sort_order), -1) AS value FROM wand_tasks WHERE workspace_id IS ? AND status = ?").get(input.workspaceId ?? null, status);
1199
1204
  const numberRow = this.db.prepare("SELECT COALESCE(MAX(CAST(substr(identifier, 6) AS INTEGER)), 0) AS value FROM wand_tasks WHERE identifier GLOB 'TASK-[0-9]*'").get();
1200
1205
  const identifier = `TASK-${(numberRow?.value ?? 0) + 1}`;
1201
- this.db.prepare(`INSERT INTO wand_tasks (id, identifier, workspace_id, workspace_task_id, title, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, identifier, input.workspaceId ?? null, input.workspaceTaskId ?? null, input.title, input.description ?? "", status, priority, JSON.stringify(input.labels ?? []), input.dueDate ?? null, (max?.value ?? -1) + 1, input.agent ? JSON.stringify(input.agent) : null, now, now);
1206
+ this.db.prepare(`INSERT INTO wand_tasks (id, identifier, workspace_id, workspace_task_id, title, title_source, description, status, priority, labels_json, due_date, sort_order, agent_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, identifier, input.workspaceId ?? null, input.workspaceTaskId ?? null, input.title, input.titleSource ?? "user", input.description ?? "", status, priority, JSON.stringify(input.labels ?? []), input.dueDate ?? null, (max?.value ?? -1) + 1, input.agent ? JSON.stringify(input.agent) : null, now, now);
1202
1207
  return this.getWandTask(id);
1203
1208
  }
1204
1209
  updateWandTask(id, patch) {
@@ -1206,7 +1211,7 @@ export class WandStorage {
1206
1211
  if (!current)
1207
1212
  return null;
1208
1213
  const next = { ...current, ...patch, updatedAt: nowIso() };
1209
- this.db.prepare(`UPDATE wand_tasks SET workspace_id = ?, workspace_task_id = ?, title = ?, description = ?, status = ?, priority = ?, labels_json = ?, due_date = ?, sort_order = ?, agent_json = ?, updated_at = ? WHERE id = ?`).run(next.workspaceId, next.workspaceTaskId, next.title, next.description, next.status, next.priority, JSON.stringify(next.labels), next.dueDate, next.sortOrder, next.agent ? JSON.stringify(next.agent) : null, next.updatedAt, id);
1214
+ this.db.prepare(`UPDATE wand_tasks SET workspace_id = ?, workspace_task_id = ?, title = ?, title_source = ?, description = ?, status = ?, priority = ?, labels_json = ?, due_date = ?, sort_order = ?, agent_json = ?, updated_at = ? WHERE id = ?`).run(next.workspaceId, next.workspaceTaskId, next.title, next.titleSource, next.description, next.status, next.priority, JSON.stringify(next.labels), next.dueDate, next.sortOrder, next.agent ? JSON.stringify(next.agent) : null, next.updatedAt, id);
1210
1215
  return next;
1211
1216
  }
1212
1217
  deleteWandTask(id) { this.db.prepare("DELETE FROM wand_tasks WHERE id = ?").run(id); }
@@ -1214,6 +1219,10 @@ export class WandStorage {
1214
1219
  const rows = this.db.prepare("SELECT session_id FROM wand_task_sessions WHERE task_id = ? ORDER BY rowid ASC, session_id ASC").all(taskId);
1215
1220
  return rows.map((row) => row.session_id);
1216
1221
  }
1222
+ listBoundWandTaskSessionIds() {
1223
+ const rows = this.db.prepare("SELECT session_id FROM wand_task_sessions").all();
1224
+ return rows.map((row) => row.session_id);
1225
+ }
1217
1226
  bindWandTaskSession(taskId, sessionId) {
1218
1227
  if (!this.getWandTask(taskId))
1219
1228
  throw new Error("未找到该任务。");
@@ -0,0 +1,14 @@
1
+ import { type QuickCommitAiOptions } from "./git-quick-commit.js";
2
+ /** 任务标题留空时由描述自动生成;面板上显示得下,也不至于截断成半句话。 */
3
+ export declare const TASK_TITLE_MAX_LENGTH = 40;
4
+ /**
5
+ * 描述已经到达客户端之后立刻可用的标题:不依赖模型,也不等网络。
6
+ * 后台自动生成成功后再覆盖它;失败或超时就保留这个兜底。
7
+ */
8
+ export declare function provisionalTaskTitleFromDescription(description: string): string;
9
+ /** 模型输出可能带引号、代码块或「标题:」前缀,逐层剥掉再按单行裁剪。 */
10
+ export declare function parseGeneratedTaskTitle(raw: string): string;
11
+ /** 识别“这不像标题”的输出:报错文案、整段句子、多行解释。 */
12
+ export declare function isPlausibleTaskTitle(value: string): boolean;
13
+ /** 由任务描述总结一个标题;模型的输出永远只作为候选,由调用方决定是否落库。 */
14
+ export declare function generateWandTaskTitle(description: string, cwd: string, language?: string, ai?: QuickCommitAiOptions): Promise<string>;
@@ -0,0 +1,96 @@
1
+ import { callConfiguredAiText } from "./git-quick-commit.js";
2
+ /** 任务标题留空时由描述自动生成;面板上显示得下,也不至于截断成半句话。 */
3
+ export const TASK_TITLE_MAX_LENGTH = 40;
4
+ const DESCRIPTION_HINT_MAX = 2_000;
5
+ function clip(value, maxLength = TASK_TITLE_MAX_LENGTH) {
6
+ if (value.length <= maxLength)
7
+ return value;
8
+ const sliced = value.slice(0, maxLength);
9
+ const lastSpace = sliced.lastIndexOf(" ");
10
+ if (lastSpace >= Math.floor(maxLength * 0.55))
11
+ return sliced.slice(0, lastSpace);
12
+ return sliced;
13
+ }
14
+ function firstMeaningfulLine(description) {
15
+ const lines = description
16
+ .split(/\r?\n/)
17
+ .map((line) => line
18
+ // Markdown 列表 / 标题 / 引用符不能变成标题的一部分。
19
+ .replace(/^\s*(?:[-*+>]+|\d+[.)]|#{1,6})\s*/, "")
20
+ .replace(/\s+/g, " ")
21
+ .trim())
22
+ .filter(Boolean);
23
+ const line = lines.find((candidate) => candidate.length >= 2) ?? lines[0] ?? "";
24
+ return line.replace(/[::;;,,。.]+$/, "").trim();
25
+ }
26
+ /**
27
+ * 描述已经到达客户端之后立刻可用的标题:不依赖模型,也不等网络。
28
+ * 后台自动生成成功后再覆盖它;失败或超时就保留这个兜底。
29
+ */
30
+ export function provisionalTaskTitleFromDescription(description) {
31
+ return clip(firstMeaningfulLine(description));
32
+ }
33
+ /** 模型输出可能带引号、代码块或「标题:」前缀,逐层剥掉再按单行裁剪。 */
34
+ export function parseGeneratedTaskTitle(raw) {
35
+ return clip(stripGeneratedTitle(raw));
36
+ }
37
+ function stripGeneratedTitle(raw) {
38
+ const withoutFences = raw.trim().replace(/^```(?:json|text)?\s*/i, "").replace(/\s*```$/i, "").trim();
39
+ const firstLine = withoutFences.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
40
+ const withoutLabel = firstLine.replace(/^(?:任务)?标题\s*[::]\s*/, "");
41
+ const unquoted = withoutLabel.replace(/^["'`“”『「]+|["'`“”』」]+$/g, "").trim();
42
+ return unquoted.replace(/[。.]+$/, "").trim();
43
+ }
44
+ /**
45
+ * CLI 把 provider 自己的报错(模型不可用、限流、鉴权失败)当成功结果返回时,
46
+ * 整句话会原样变成任务标题。这些字符串一律不能当标题落库。
47
+ */
48
+ const TITLE_REJECT_PATTERNS = [
49
+ /\b(?:i(?:'| a)?m )?sorry\b/i,
50
+ /\bthere(?:'| i)?s?\s+an?\s+issue\b/i,
51
+ /\b(?:api|model|provider|authentication|rate)[ _-]?(?:error|limit|key|quota)\b/i,
52
+ /\b(?:invalid|missing|expired)\b[^.]{0,24}\b(?:key|token|api|model|credential)\b/i,
53
+ /\b(?:unauthorized|forbidden|permission denied|quota exceeded|not found|timed? ?out)\b/i,
54
+ /\b(?:error|exception|failed|failure)\s*[::]/i,
55
+ /^(?:检索|抱歉|对不起|无法|不能|错错|错误|失败|请求超时|服务不可用)/,
56
+ ];
57
+ /** 识别“这不像标题”的输出:报错文案、整段句子、多行解释。 */
58
+ export function isPlausibleTaskTitle(value) {
59
+ const title = value.replace(/\s+/g, " ").trim();
60
+ if (!title)
61
+ return false;
62
+ // 超过一行标题的长度上限就不再是标题,而是解释。
63
+ if (title.length > TASK_TITLE_MAX_LENGTH)
64
+ return false;
65
+ if (TITLE_REJECT_PATTERNS.some((pattern) => pattern.test(title)))
66
+ return false;
67
+ // 标题是一行短语:出现句末标点,或长句里带逗号分号,都说明这是解释而不是标题。
68
+ if (/[。!?!?]/.test(title))
69
+ return false;
70
+ if (title.length > 24 && /[,,;;]/.test(title))
71
+ return false;
72
+ return true;
73
+ }
74
+ /** 由任务描述总结一个标题;模型的输出永远只作为候选,由调用方决定是否落库。 */
75
+ export async function generateWandTaskTitle(description, cwd, language = "", ai = {}) {
76
+ const input = description.trim().slice(0, DESCRIPTION_HINT_MAX);
77
+ if (!input)
78
+ throw new Error("没有可总结的任务描述。");
79
+ const outputLanguage = language.trim() || "与描述相同的语言";
80
+ const prompt = [
81
+ "根据下面的任务描述,写一个用于任务列表的具体标题。",
82
+ `使用${outputLanguage}输出。`,
83
+ "只输出标题本身:一行纯文本,不要引号、不要 Markdown、不要编号、不要「标题:」这类前缀、不要任何解释。",
84
+ "不超过 20 个字,必须点出描述里的具体对象或动作,不要使用「任务」「待办」「关于」「请求」等空泛词。",
85
+ "",
86
+ "任务描述:",
87
+ input,
88
+ ].join("\n");
89
+ const raw = await callConfiguredAiText(prompt, cwd || process.cwd(), language, ai);
90
+ const stripped = stripGeneratedTitle(raw);
91
+ // 拿到报错文案 / 整段解释时宁可保留占位标题,也不能把垃圾写进看板。
92
+ // 先判可信度再裁剪:截断过的长句看起来就像标题,会绕过长度检查。
93
+ if (!isPlausibleTaskTitle(stripped))
94
+ throw new Error("模型返回的任务标题不可用。");
95
+ return clip(stripped);
96
+ }
@@ -11,6 +11,8 @@ export interface WandTaskAgent {
11
11
  model: WandTaskAgentModel;
12
12
  thinkingEffort: WandTaskAgentEffort;
13
13
  }
14
+ /** 任务标题来源;标题是可选字段,留空时由服务端按描述自动生成。 */
15
+ export type WandTaskTitleSource = "user" | "auto";
14
16
  export interface WandTask {
15
17
  id: string;
16
18
  workspaceId: string | null;
@@ -18,6 +20,8 @@ export interface WandTask {
18
20
  workspaceTaskId: string | null;
19
21
  identifier: string;
20
22
  title: string;
23
+ /** 'user' = 用户自己填的标题;'auto' = 用户留空后按描述自动生成。 */
24
+ titleSource: WandTaskTitleSource;
21
25
  description: string;
22
26
  status: WandTaskStatus;
23
27
  priority: WandTaskPriority;
package/dist/types.d.ts CHANGED
@@ -89,6 +89,17 @@ export interface WandConfig {
89
89
  certPath?: string;
90
90
  keyPath?: string;
91
91
  };
92
+ /**
93
+ * 对外公开的访问地址(如 `https://home.example.com:8443`)。
94
+ *
95
+ * TLS 由 L4 反代(nginx stream / Nginx Proxy Manager 的 TCP stream)终止时,node 收到的是
96
+ * 明文 HTTP,既没有 `X-Forwarded-Proto` 也没有真实 scheme,`/api/app-connect-code` 与
97
+ * `/api/login` 的 `serverUrl` 会把 `https://host:tls-port` 误判成 `http://host:tls-port`,
98
+ * 手机扫描连接码后对 TLS 端口发明文请求,报“连不上服务端”。
99
+ *
100
+ * 填了就以这里为准(含 scheme),不再猜测。留空则沿用请求头 / 浏览器 origin 推断。
101
+ */
102
+ publicOrigin?: string;
92
103
  password: string;
93
104
  /** 新建会话时默认使用的 Provider。 */
94
105
  defaultProvider?: SessionProvider;
@@ -1,8 +1,16 @@
1
1
  import type { WandStorage } from "./storage.js";
2
2
  import type { WandTask } from "./task-types.js";
3
- import type { Workspace, WorkspaceTask } from "./types.js";
3
+ import type { SessionSnapshot, Workspace, WorkspaceTask } from "./types.js";
4
+ export declare function isUnnamedWorkspaceTaskName(name: string): boolean;
5
+ /** 从未分组会话的标题 / 描述 / 首条用户消息里抽出看板标题。 */
6
+ export declare function boardTitleFromSession(session: SessionSnapshot): string;
4
7
  /** 侧栏工作任务与看板卡片对账:已关联则复用,同项目同名未关联则挂上,否则新建。 */
5
- export declare function ensureBoardTaskForWorkspaceTask(storage: WandStorage, task: WorkspaceTask, workspace: Workspace): WandTask;
8
+ export declare function ensureBoardTaskForWorkspaceTask(storage: WandStorage, task: WorkspaceTask, workspace: Workspace): WandTask | null;
9
+ /**
10
+ * 把历史未分组会话补到看板:用会话标题/首条消息当标题,绑上会话,放进「进行中」。
11
+ * 幂等,打开看板列表时调用。
12
+ */
13
+ export declare function syncUngroupedSessionsToBoard(storage: WandStorage): void;
6
14
  /** 看板归档:卡片进入已完成,并同步把关联的侧栏工作任务标成 done(不删会话)。 */
7
15
  export declare function archiveBoardTask(storage: WandStorage, id: string): WandTask | null;
8
16
  /** 侧栏工作任务完成/删除时,把对应看板卡片标成已完成。 */