@qnote/q-ai-note 1.0.13 → 1.0.14

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/dist/web/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { API_BASE, apiRequest, escapeHtml, safeText, appendLoadingMessage, setButtonState } from './shared.js';
2
2
  import { renderChatEntry } from './chatView.js';
3
3
  import { mountSandboxGrid, mountHtmlList, mountDiaryTimeline, mountWorkTree } from './vueRenderers.js';
4
+ import { createAserRuntimeView } from './aserRuntimeView.js';
4
5
 
5
6
  const state = {
6
7
  sandboxes: [],
@@ -39,6 +40,9 @@ const state = {
39
40
  sandboxSortMode: 'created_asc',
40
41
  workItemSearch: '',
41
42
  workItemStatusFilter: 'all',
43
+ taskBoardPeriod: 'week',
44
+ taskBoardOwnerName: '',
45
+ taskBoardData: null,
42
46
  diarySearch: '',
43
47
  diarySandboxFilter: '',
44
48
  diaryProcessedFilter: 'all',
@@ -47,6 +51,16 @@ const state = {
47
51
  changesTypeFilter: 'all',
48
52
  changesQuickFilter: 'all',
49
53
  workTreeExpandInitialized: false,
54
+ agentClub: [],
55
+ teams: [],
56
+ aserProjectTeamAssignments: {},
57
+ opencodeRunnerProjects: [],
58
+ opencodeRunnerSelectedProjectId: '',
59
+ opencodeRunnerSelectedTeamId: '',
60
+ opencodeRunnerTasks: [],
61
+ opencodeRunnerTimeline: [],
62
+ opencodeRunnerRunning: false,
63
+ opencodeRunnerPlanned: false,
50
64
  };
51
65
 
52
66
  const expandedNodes = new Set();
@@ -54,9 +68,14 @@ let quickChatPopover = null;
54
68
  let sandboxActionHandler = null;
55
69
  let sandboxEscLocked = false;
56
70
  let resizeRenderTimer = null;
71
+ let aserRuntimeView = null;
57
72
  const WORK_ITEM_SHOW_ASSIGNEE_STORAGE_KEY = 'q-ai-note.work-item.show-assignee';
58
73
  const WORK_TREE_VIEW_MODE_STORAGE_KEY = 'q-ai-note.work-tree.view-mode';
59
74
  const SANDBOX_SORT_MODE_STORAGE_KEY = 'q-ai-note.sandbox.sort-mode';
75
+ const TASK_BOARD_OWNER_STORAGE_KEY = 'q-ai-note.task-board.owner-name';
76
+ const AGENT_CLUB_STORAGE_KEY = 'q-ai-note.ai-engineering.agent-club';
77
+ const TEAMS_STORAGE_KEY = 'q-ai-note.ai-engineering.teams';
78
+ const ASER_PROJECT_TEAM_ASSIGNMENTS_STORAGE_KEY = 'q-ai-note.ai-engineering.aser-project-team-assignments';
60
79
 
61
80
  function loadWorkItemAssigneePreference() {
62
81
  try {
@@ -118,6 +137,509 @@ function persistSandboxSortModePreference() {
118
137
  }
119
138
  }
120
139
 
140
+ function loadTaskBoardOwnerNamePreference() {
141
+ try {
142
+ state.taskBoardOwnerName = String(window.localStorage.getItem(TASK_BOARD_OWNER_STORAGE_KEY) || '').trim();
143
+ } catch {
144
+ state.taskBoardOwnerName = '';
145
+ }
146
+ }
147
+
148
+ function persistTaskBoardOwnerNamePreference() {
149
+ try {
150
+ window.localStorage.setItem(TASK_BOARD_OWNER_STORAGE_KEY, state.taskBoardOwnerName || '');
151
+ } catch {
152
+ // Ignore storage failures in restricted environments.
153
+ }
154
+ }
155
+
156
+ function safeParseJson(rawValue, fallback) {
157
+ if (!rawValue) return fallback;
158
+ try {
159
+ return JSON.parse(rawValue);
160
+ } catch {
161
+ return fallback;
162
+ }
163
+ }
164
+
165
+ function parseLineList(rawValue) {
166
+ return String(rawValue || '')
167
+ .split('\n')
168
+ .map((value) => value.trim())
169
+ .filter(Boolean);
170
+ }
171
+
172
+ function parseSkillRows(rawValue) {
173
+ return parseLineList(rawValue).map((row) => {
174
+ const [skillPart, levelPart = ''] = row.split(':');
175
+ const skill = String(skillPart || '').trim();
176
+ const level = String(levelPart || '').trim();
177
+ return {
178
+ skill,
179
+ level: level || 'intermediate',
180
+ };
181
+ }).filter((row) => Boolean(row.skill));
182
+ }
183
+
184
+ function normalizeAgentRecord(rawAgent = {}) {
185
+ const id = String(rawAgent.id || '').trim() || `agent-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
186
+ const roleType = String(rawAgent.role_type || rawAgent.role || '').trim() || 'generalist';
187
+ const legacySkill = String(rawAgent.skill || '').trim();
188
+ const rawSkills = Array.isArray(rawAgent.skills) ? rawAgent.skills : [];
189
+ const parsedSkills = rawSkills
190
+ .map((row) => {
191
+ if (!row || typeof row !== 'object') return null;
192
+ const skill = String(row.skill || '').trim();
193
+ const level = String(row.level || '').trim() || 'intermediate';
194
+ if (!skill) return null;
195
+ return { skill, level };
196
+ })
197
+ .filter(Boolean);
198
+ return {
199
+ id,
200
+ name: String(rawAgent.name || '').trim() || id,
201
+ role: roleType,
202
+ role_type: roleType,
203
+ knowledge_background: Array.isArray(rawAgent.knowledge_background)
204
+ ? rawAgent.knowledge_background.map((row) => String(row || '').trim()).filter(Boolean)
205
+ : [],
206
+ skills: parsedSkills.length ? parsedSkills : (legacySkill ? [{ skill: legacySkill, level: 'intermediate' }] : []),
207
+ task_types: Array.isArray(rawAgent.task_types)
208
+ ? rawAgent.task_types.map((row) => String(row || '').trim()).filter(Boolean)
209
+ : [],
210
+ deliverables: Array.isArray(rawAgent.deliverables)
211
+ ? rawAgent.deliverables.map((row) => String(row || '').trim()).filter(Boolean)
212
+ : [],
213
+ raci_default: String(rawAgent.raci_default || '').trim(),
214
+ quality_criteria: Array.isArray(rawAgent.quality_criteria)
215
+ ? rawAgent.quality_criteria.map((row) => String(row || '').trim()).filter(Boolean)
216
+ : [],
217
+ risk_limits: Array.isArray(rawAgent.risk_limits)
218
+ ? rawAgent.risk_limits.map((row) => String(row || '').trim()).filter(Boolean)
219
+ : [],
220
+ created_at: String(rawAgent.created_at || new Date().toISOString()),
221
+ };
222
+ }
223
+
224
+ function normalizeTeamRecord(rawTeam = {}) {
225
+ const id = String(rawTeam.id || '').trim() || `team-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
226
+ const agentIds = Array.isArray(rawTeam.agent_ids) ? rawTeam.agent_ids.map((idValue) => String(idValue || '').trim()).filter(Boolean) : [];
227
+ return {
228
+ id,
229
+ name: String(rawTeam.name || '').trim() || id,
230
+ agent_ids: Array.from(new Set(agentIds)),
231
+ created_at: String(rawTeam.created_at || new Date().toISOString()),
232
+ };
233
+ }
234
+
235
+ function loadAiEngineeringState() {
236
+ try {
237
+ const rawAgentClub = window.localStorage.getItem(AGENT_CLUB_STORAGE_KEY);
238
+ const rawTeams = window.localStorage.getItem(TEAMS_STORAGE_KEY);
239
+ const rawAssignments = window.localStorage.getItem(ASER_PROJECT_TEAM_ASSIGNMENTS_STORAGE_KEY);
240
+ const parsedAgentClub = safeParseJson(rawAgentClub, []);
241
+ const parsedTeams = safeParseJson(rawTeams, []);
242
+ const parsedAssignments = safeParseJson(rawAssignments, {});
243
+ state.agentClub = Array.isArray(parsedAgentClub) ? parsedAgentClub.map((row) => normalizeAgentRecord(row)) : [];
244
+ state.teams = Array.isArray(parsedTeams) ? parsedTeams.map((row) => normalizeTeamRecord(row)) : [];
245
+ state.aserProjectTeamAssignments = parsedAssignments && typeof parsedAssignments === 'object' ? parsedAssignments : {};
246
+ } catch {
247
+ state.agentClub = [];
248
+ state.teams = [];
249
+ state.aserProjectTeamAssignments = {};
250
+ }
251
+ }
252
+
253
+ function persistAiEngineeringState() {
254
+ try {
255
+ window.localStorage.setItem(AGENT_CLUB_STORAGE_KEY, JSON.stringify(state.agentClub));
256
+ window.localStorage.setItem(TEAMS_STORAGE_KEY, JSON.stringify(state.teams));
257
+ window.localStorage.setItem(
258
+ ASER_PROJECT_TEAM_ASSIGNMENTS_STORAGE_KEY,
259
+ JSON.stringify(state.aserProjectTeamAssignments || {}),
260
+ );
261
+ } catch {
262
+ // Ignore storage failures in restricted environments.
263
+ }
264
+ }
265
+
266
+ function getAgentNameById(agentId) {
267
+ const id = String(agentId || '').trim();
268
+ const row = state.agentClub.find((agent) => String(agent.id) === id);
269
+ if (!row) return id || '-';
270
+ return `${row.name} (${row.role})`;
271
+ }
272
+
273
+ function renderAgentClub() {
274
+ const list = document.getElementById('agent-club-list');
275
+ if (!(list instanceof HTMLElement)) return;
276
+ if (!state.agentClub.length) {
277
+ list.innerHTML = '<div class="aser-empty">暂无 Agent,可先创建。</div>';
278
+ return;
279
+ }
280
+ list.innerHTML = state.agentClub
281
+ .map((agent) => {
282
+ const knowledgeText = Array.isArray(agent.knowledge_background) && agent.knowledge_background.length
283
+ ? agent.knowledge_background.join(';')
284
+ : '-';
285
+ const skillsText = Array.isArray(agent.skills) && agent.skills.length
286
+ ? agent.skills.map((row) => `${row.skill}(${row.level})`).join(';')
287
+ : '-';
288
+ const taskTypesText = Array.isArray(agent.task_types) && agent.task_types.length ? agent.task_types.join(';') : '-';
289
+ const deliverablesText = Array.isArray(agent.deliverables) && agent.deliverables.length ? agent.deliverables.join(';') : '-';
290
+ const qualityText = Array.isArray(agent.quality_criteria) && agent.quality_criteria.length ? agent.quality_criteria.join(';') : '-';
291
+ const riskText = Array.isArray(agent.risk_limits) && agent.risk_limits.length ? agent.risk_limits.join(';') : '-';
292
+ return `
293
+ <article class="aser-project-item">
294
+ <div>
295
+ <strong>${escapeHtml(agent.name)}</strong>
296
+ <div class="aser-project-id">${escapeHtml(agent.id)}</div>
297
+ <div class="aser-meta-item">角色:${escapeHtml(agent.role_type || agent.role || 'generalist')}</div>
298
+ <div class="aser-meta-item">知识背景:${escapeHtml(knowledgeText)}</div>
299
+ <div class="aser-meta-item">技能:${escapeHtml(skillsText)}</div>
300
+ <div class="aser-meta-item">任务类型:${escapeHtml(taskTypesText)}</div>
301
+ <div class="aser-meta-item">产物类型:${escapeHtml(deliverablesText)}</div>
302
+ <div class="aser-meta-item">RACI:${escapeHtml(agent.raci_default || '-')}</div>
303
+ <div class="aser-meta-item">质量标准:${escapeHtml(qualityText)}</div>
304
+ <div class="aser-meta-item">风险边界:${escapeHtml(riskText)}</div>
305
+ </div>
306
+ <button class="btn btn-secondary btn-sm" type="button" data-agent-delete-id="${escapeHtml(agent.id)}">删除</button>
307
+ </article>
308
+ `;
309
+ })
310
+ .join('');
311
+ }
312
+
313
+ function renderTeams() {
314
+ renderTeamAgentSelector();
315
+ const list = document.getElementById('teams-list');
316
+ if (!(list instanceof HTMLElement)) return;
317
+ if (!state.teams.length) {
318
+ list.innerHTML = '<div class="aser-empty">暂无 Team,可先创建。</div>';
319
+ return;
320
+ }
321
+ list.innerHTML = state.teams
322
+ .map((team) => {
323
+ const memberText = team.agent_ids.length ? team.agent_ids.map((id) => escapeHtml(getAgentNameById(id))).join(',') : '无成员';
324
+ return `
325
+ <article class="aser-project-item">
326
+ <div>
327
+ <strong>${escapeHtml(team.name)}</strong>
328
+ <div class="aser-project-id">${escapeHtml(team.id)}</div>
329
+ <div class="aser-meta-item">成员:${memberText}</div>
330
+ </div>
331
+ <button class="btn btn-secondary btn-sm" type="button" data-team-delete-id="${escapeHtml(team.id)}">删除</button>
332
+ </article>
333
+ `;
334
+ })
335
+ .join('');
336
+ }
337
+
338
+ function renderTeamAgentSelector() {
339
+ const select = document.getElementById('teams-agent-select');
340
+ if (!(select instanceof HTMLSelectElement)) return;
341
+ if (!state.agentClub.length) {
342
+ select.innerHTML = '<option value="" disabled>暂无 Agent,请先到 Agent Club 创建</option>';
343
+ return;
344
+ }
345
+ select.innerHTML = state.agentClub
346
+ .map((agent) => `<option value="${escapeHtml(agent.id)}">${escapeHtml(agent.name)} (${escapeHtml(agent.role)})</option>`)
347
+ .join('');
348
+ }
349
+
350
+ function assignAserProjectTeam(projectId, teamId) {
351
+ const pid = String(projectId || '').trim();
352
+ if (!pid) return;
353
+ const normalizedTeamId = String(teamId || '').trim();
354
+ if (normalizedTeamId) {
355
+ state.aserProjectTeamAssignments[pid] = normalizedTeamId;
356
+ } else {
357
+ delete state.aserProjectTeamAssignments[pid];
358
+ }
359
+ persistAiEngineeringState();
360
+ renderOpenCodeRunner();
361
+ }
362
+
363
+ function formatLocalTime(value) {
364
+ if (!value) return '-';
365
+ const date = new Date(value);
366
+ if (Number.isNaN(date.getTime())) return String(value);
367
+ return date.toLocaleString();
368
+ }
369
+
370
+ function getTeamById(teamId) {
371
+ const id = String(teamId || '').trim();
372
+ if (!id) return null;
373
+ return state.teams.find((team) => String(team.id) === id) || null;
374
+ }
375
+
376
+ function getAssignedTeamIdForProject(projectId) {
377
+ const pid = String(projectId || '').trim();
378
+ if (!pid) return '';
379
+ return String(state.aserProjectTeamAssignments?.[pid] || '').trim();
380
+ }
381
+
382
+ function appendRunnerTimeline(row = {}) {
383
+ state.opencodeRunnerTimeline.unshift({
384
+ id: `runner-log-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
385
+ time: new Date().toISOString(),
386
+ ...row,
387
+ });
388
+ }
389
+
390
+ function inferTaskTaxonomy(task, intents) {
391
+ const intentId = String(task?.intent_id || '').trim();
392
+ const intent = (Array.isArray(intents) ? intents : []).find((row) => String(row.id) === intentId);
393
+ return String(intent?.taxonomy || '').trim();
394
+ }
395
+
396
+ function inferCommunicationDemand(taxonomy) {
397
+ const value = String(taxonomy || '').trim();
398
+ if (value === 'clarify_design') return 'clarification';
399
+ if (value === 'escalate_issue' || value === 'report_issue') return 'escalation';
400
+ return '';
401
+ }
402
+
403
+ function getRoleArtifactTemplates(role) {
404
+ const value = String(role || '').trim();
405
+ if (value === 'product_manager') return [{ type: 'requirement_doc', summary: '需求分解与验收标准已提交' }];
406
+ if (value === 'architect') return [{ type: 'design_doc', summary: '系统设计方案与约束说明已提交' }];
407
+ if (value === 'reviewer') return [{ type: 'review_record', summary: '质量评审记录已提交' }];
408
+ return [
409
+ { type: 'pr', summary: '实现代码与变更说明已提交' },
410
+ { type: 'test_report', summary: '测试报告与通过结论已提交' },
411
+ ];
412
+ }
413
+
414
+ function pickAgentForTask(team, task) {
415
+ const members = Array.isArray(team?.agent_ids) ? team.agent_ids : [];
416
+ const ownerRole = String(task?.owner_role || '').trim();
417
+ const exact = members
418
+ .map((id) => state.agentClub.find((agent) => String(agent.id) === String(id)))
419
+ .find((agent) => String(agent?.role_type || agent?.role || '').trim() === ownerRole);
420
+ if (exact) return exact;
421
+ return members
422
+ .map((id) => state.agentClub.find((agent) => String(agent.id) === String(id)))
423
+ .find(Boolean) || null;
424
+ }
425
+
426
+ async function loadOpenCodeRunnerProjects() {
427
+ if (!state.pageAccess.sandboxes && !state.fullAccess) return;
428
+ const projects = await apiRequest(`${API_BASE}/aser-runtime/projects`);
429
+ state.opencodeRunnerProjects = Array.isArray(projects) ? projects : [];
430
+ if (!state.opencodeRunnerSelectedProjectId && state.opencodeRunnerProjects.length) {
431
+ state.opencodeRunnerSelectedProjectId = String(state.opencodeRunnerProjects[0].id || '');
432
+ }
433
+ if (state.opencodeRunnerSelectedProjectId
434
+ && !state.opencodeRunnerProjects.some((project) => String(project.id) === String(state.opencodeRunnerSelectedProjectId))) {
435
+ state.opencodeRunnerSelectedProjectId = state.opencodeRunnerProjects[0]
436
+ ? String(state.opencodeRunnerProjects[0].id || '')
437
+ : '';
438
+ }
439
+ const assignedTeamId = getAssignedTeamIdForProject(state.opencodeRunnerSelectedProjectId);
440
+ state.opencodeRunnerSelectedTeamId = assignedTeamId || state.opencodeRunnerSelectedTeamId || String(state.teams[0]?.id || '');
441
+ }
442
+
443
+ function renderOpenCodeRunner() {
444
+ const projectSelect = document.getElementById('opencode-runner-project-select');
445
+ const teamSelect = document.getElementById('opencode-runner-team-select');
446
+ const taskList = document.getElementById('opencode-runner-task-list');
447
+ const timeline = document.getElementById('opencode-runner-timeline');
448
+ const summary = document.getElementById('opencode-runner-summary');
449
+ const loadBtn = document.getElementById('opencode-runner-load-btn');
450
+ const planBtn = document.getElementById('opencode-runner-plan-btn');
451
+ const startBtn = document.getElementById('opencode-runner-start-btn');
452
+ if (!(projectSelect instanceof HTMLSelectElement)
453
+ || !(teamSelect instanceof HTMLSelectElement)
454
+ || !(taskList instanceof HTMLElement)
455
+ || !(timeline instanceof HTMLElement)
456
+ || !(summary instanceof HTMLElement)) {
457
+ return;
458
+ }
459
+
460
+ if (!state.opencodeRunnerProjects.length) {
461
+ projectSelect.innerHTML = '<option value="">暂无 ASER Project</option>';
462
+ } else {
463
+ projectSelect.innerHTML = state.opencodeRunnerProjects
464
+ .map((project) => `<option value="${escapeHtml(project.id)}">${escapeHtml(project.name || project.id)}</option>`)
465
+ .join('');
466
+ projectSelect.value = String(state.opencodeRunnerSelectedProjectId || state.opencodeRunnerProjects[0].id || '');
467
+ }
468
+
469
+ if (!state.teams.length) {
470
+ teamSelect.innerHTML = '<option value="">暂无 Team(先在 Teams 页面创建)</option>';
471
+ } else {
472
+ teamSelect.innerHTML = state.teams
473
+ .map((team) => `<option value="${escapeHtml(team.id)}">${escapeHtml(team.name || team.id)}</option>`)
474
+ .join('');
475
+ if (!state.opencodeRunnerSelectedTeamId || !state.teams.some((team) => String(team.id) === String(state.opencodeRunnerSelectedTeamId))) {
476
+ state.opencodeRunnerSelectedTeamId = String(state.teams[0].id || '');
477
+ }
478
+ teamSelect.value = state.opencodeRunnerSelectedTeamId;
479
+ }
480
+
481
+ const selectedTeam = getTeamById(state.opencodeRunnerSelectedTeamId);
482
+ const totalTasks = state.opencodeRunnerTasks.length;
483
+ const doneTasks = state.opencodeRunnerTasks.filter((row) => ['accepted', 'completed'].includes(String(row.status || ''))).length;
484
+ const commTasks = state.opencodeRunnerTasks.filter((row) => String(row.communication_demand || '')).length;
485
+ summary.textContent = `项目 ${state.opencodeRunnerSelectedProjectId || '-'} | Team ${selectedTeam?.name || '-'} | 任务 ${doneTasks}/${totalTasks} 已完成 | 沟通诉求 ${commTasks}`;
486
+
487
+ if (!state.opencodeRunnerTasks.length) {
488
+ taskList.innerHTML = '<div class="aser-empty">点击“获取任务”加载当前 Project 的任务池。</div>';
489
+ } else {
490
+ taskList.innerHTML = state.opencodeRunnerTasks
491
+ .map((task) => `
492
+ <article class="aser-project-item">
493
+ <div>
494
+ <strong>${escapeHtml(task.title || task.id)}</strong>
495
+ <div class="aser-project-id">${escapeHtml(task.id)}</div>
496
+ <div class="aser-meta-item">责任角色:${escapeHtml(task.owner_role || '-')} | Agent:${escapeHtml(task.assigned_agent_name || '-')}</div>
497
+ <div class="aser-meta-item">状态:${escapeHtml(task.status || '-')} | 意图:${escapeHtml(task.taxonomy || '-')}</div>
498
+ <div class="aser-meta-item">诉求:${escapeHtml(task.communication_demand || '-')}</div>
499
+ </div>
500
+ </article>
501
+ `)
502
+ .join('');
503
+ }
504
+
505
+ if (!state.opencodeRunnerTimeline.length) {
506
+ timeline.innerHTML = '<div class="aser-empty">暂无过程日志。</div>';
507
+ } else {
508
+ timeline.innerHTML = state.opencodeRunnerTimeline
509
+ .map((row) => `
510
+ <article class="aser-chat-task-interaction">
511
+ <strong>${escapeHtml(row.phase || 'event')}</strong> · ${escapeHtml(formatLocalTime(row.time))}
512
+ <div>${escapeHtml(row.message || '-')}</div>
513
+ </article>
514
+ `)
515
+ .join('');
516
+ }
517
+
518
+ const writeDisabled = state.readonly || !state.fullAccess;
519
+ if (loadBtn instanceof HTMLButtonElement) loadBtn.disabled = state.opencodeRunnerRunning;
520
+ if (planBtn instanceof HTMLButtonElement) planBtn.disabled = state.opencodeRunnerRunning || !state.opencodeRunnerTasks.length;
521
+ if (startBtn instanceof HTMLButtonElement) {
522
+ startBtn.disabled = writeDisabled
523
+ || state.opencodeRunnerRunning
524
+ || !state.opencodeRunnerTasks.length
525
+ || !state.opencodeRunnerSelectedTeamId;
526
+ }
527
+ }
528
+
529
+ async function loadOpenCodeRunnerTasks() {
530
+ const projectId = String(state.opencodeRunnerSelectedProjectId || '').trim();
531
+ if (!projectId) return;
532
+ const detail = await apiRequest(`${API_BASE}/aser-runtime/projects/${projectId}`);
533
+ const intents = Array.isArray(detail?.intents) ? detail.intents : [];
534
+ const selectedTeam = getTeamById(state.opencodeRunnerSelectedTeamId);
535
+ const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [];
536
+ state.opencodeRunnerTasks = tasks.map((task) => {
537
+ const taxonomy = inferTaskTaxonomy(task, intents);
538
+ const communicationDemand = inferCommunicationDemand(taxonomy);
539
+ const assigned = pickAgentForTask(selectedTeam, task);
540
+ return {
541
+ id: String(task.id || ''),
542
+ title: String(task.title || ''),
543
+ owner_role: String(task.owner_role || ''),
544
+ status: String(task.status || ''),
545
+ taxonomy,
546
+ communication_demand: communicationDemand,
547
+ assigned_agent_id: String(assigned?.id || ''),
548
+ assigned_agent_name: String(assigned?.name || ''),
549
+ };
550
+ });
551
+ state.opencodeRunnerPlanned = false;
552
+ appendRunnerTimeline({
553
+ phase: 'tasks_fetched',
554
+ message: `已获取任务 ${state.opencodeRunnerTasks.length} 条,准备进入编排。`,
555
+ });
556
+ renderOpenCodeRunner();
557
+ }
558
+
559
+ function planOpenCodeRunnerTasks() {
560
+ if (!state.opencodeRunnerTasks.length) return;
561
+ state.opencodeRunnerTasks.forEach((task) => {
562
+ appendRunnerTimeline({
563
+ phase: 'dispatch',
564
+ message: `任务 ${task.title || task.id} -> ${task.owner_role || '-'} / ${task.assigned_agent_name || '-'}`,
565
+ });
566
+ });
567
+ state.opencodeRunnerPlanned = true;
568
+ renderOpenCodeRunner();
569
+ }
570
+
571
+ async function startOpenCodeRunner() {
572
+ const projectId = String(state.opencodeRunnerSelectedProjectId || '').trim();
573
+ const teamId = String(state.opencodeRunnerSelectedTeamId || '').trim();
574
+ if (!projectId || !teamId || state.opencodeRunnerRunning) return;
575
+ const team = getTeamById(teamId);
576
+ if (!team) return;
577
+
578
+ state.opencodeRunnerRunning = true;
579
+ renderOpenCodeRunner();
580
+ appendRunnerTimeline({ phase: 'run_start', message: `启动 OpenCode Team Runner,Team=${team.name}` });
581
+
582
+ try {
583
+ for (const task of state.opencodeRunnerTasks) {
584
+ const status = String(task.status || '');
585
+ if (status === 'accepted' || status === 'completed') {
586
+ appendRunnerTimeline({ phase: 'skip', message: `跳过已完成任务:${task.title || task.id}` });
587
+ continue;
588
+ }
589
+ const role = String(task.owner_role || 'developer') || 'developer';
590
+ appendRunnerTimeline({ phase: 'agent_start', message: `启动 Agent:${task.assigned_agent_name || role},处理任务 ${task.title || task.id}` });
591
+
592
+ const pulled = await apiRequest(`${API_BASE}/aser-runtime/projects/${projectId}/pull-task?role=${encodeURIComponent(role)}`);
593
+ const pulledTaskId = String(pulled?.task?.id || '');
594
+ if (!pulledTaskId) {
595
+ appendRunnerTimeline({ phase: 'blocked', message: `角色 ${role} 当前无可拉取任务,等待调度。` });
596
+ continue;
597
+ }
598
+
599
+ const artifacts = getRoleArtifactTemplates(role).map((row, index) => ({
600
+ type: row.type,
601
+ uri: `opencode://runner/${projectId}/${pulledTaskId}/${index + 1}`,
602
+ summary: row.summary,
603
+ }));
604
+ await apiRequest(`${API_BASE}/aser-runtime/projects/${projectId}/tasks/${pulledTaskId}/submit-result`, {
605
+ method: 'POST',
606
+ body: JSON.stringify({
607
+ role,
608
+ summary: `OpenCode Runner completed by ${task.assigned_agent_name || role}`,
609
+ artifacts,
610
+ }),
611
+ });
612
+
613
+ const decision = await apiRequest(`${API_BASE}/aser-runtime/projects/${projectId}/tasks/${pulledTaskId}/evaluations`, {
614
+ method: 'POST',
615
+ body: JSON.stringify({
616
+ evaluator: task.assigned_agent_name || `Runner-${role}`,
617
+ decision: 'accept',
618
+ score: 90,
619
+ comments: 'OpenCode Team Runner: auto-accepted by orchestration policy',
620
+ }),
621
+ });
622
+ appendRunnerTimeline({
623
+ phase: 'agent_done',
624
+ message: `任务 ${task.title || pulledTaskId} 完成,评价=${String(decision?.decision || 'accept')}`,
625
+ });
626
+ if (task.communication_demand === 'clarification') {
627
+ appendRunnerTimeline({ phase: 'communication', message: `澄清诉求:${task.title || pulledTaskId} 需要进一步业务澄清。` });
628
+ }
629
+ if (task.communication_demand === 'escalation') {
630
+ appendRunnerTimeline({ phase: 'communication', message: `上升诉求:${task.title || pulledTaskId} 存在风险需上升管理。` });
631
+ }
632
+ }
633
+ appendRunnerTimeline({ phase: 'run_done', message: 'OpenCode Team Runner 执行结束。' });
634
+ await loadOpenCodeRunnerTasks();
635
+ } catch (error) {
636
+ appendRunnerTimeline({ phase: 'run_error', message: `执行失败:${error?.message || error}` });
637
+ } finally {
638
+ state.opencodeRunnerRunning = false;
639
+ renderOpenCodeRunner();
640
+ }
641
+ }
642
+
121
643
  function compareSandboxesBySortMode(a, b) {
122
644
  const mode = String(state.sandboxSortMode || 'created_asc');
123
645
  const createdA = String(a?.created_at || '');
@@ -222,10 +744,34 @@ function applyReadonlyMode() {
222
744
  if (diariesNav instanceof HTMLElement) {
223
745
  diariesNav.classList.toggle('hidden', !state.pageAccess.diaries && !state.fullAccess);
224
746
  }
747
+ const tasksNav = document.querySelector('[data-nav="tasks"]');
748
+ if (tasksNav instanceof HTMLElement) {
749
+ tasksNav.classList.toggle('hidden', !state.pageAccess.sandboxes && !state.fullAccess);
750
+ }
225
751
  const changesNav = document.querySelector('[data-nav="changes"]');
226
752
  if (changesNav instanceof HTMLElement) {
227
753
  changesNav.classList.toggle('hidden', !state.pageAccess.changes && !state.fullAccess);
228
754
  }
755
+ const aserRuntimeNav = document.querySelector('[data-nav="aser-runtime"]');
756
+ if (aserRuntimeNav instanceof HTMLElement) {
757
+ aserRuntimeNav.classList.toggle('hidden', !state.pageAccess.sandboxes && !state.fullAccess);
758
+ }
759
+ const aiEngineeringNav = document.querySelector('[data-nav="ai-engineering"]');
760
+ if (aiEngineeringNav instanceof HTMLElement) {
761
+ aiEngineeringNav.classList.toggle('hidden', !state.pageAccess.sandboxes && !state.fullAccess);
762
+ }
763
+ const agentClubNav = document.querySelector('[data-nav="agent-club"]');
764
+ if (agentClubNav instanceof HTMLElement) {
765
+ agentClubNav.classList.toggle('hidden', !state.pageAccess.sandboxes && !state.fullAccess);
766
+ }
767
+ const teamsNav = document.querySelector('[data-nav="teams"]');
768
+ if (teamsNav instanceof HTMLElement) {
769
+ teamsNav.classList.toggle('hidden', !state.pageAccess.sandboxes && !state.fullAccess);
770
+ }
771
+ const opencodeRunnerNav = document.querySelector('[data-nav="opencode-team-runner"]');
772
+ if (opencodeRunnerNav instanceof HTMLElement) {
773
+ opencodeRunnerNav.classList.toggle('hidden', !state.pageAccess.sandboxes && !state.fullAccess);
774
+ }
229
775
  const projectSettingsNav = document.querySelector('[data-nav="settings"]');
230
776
  if (projectSettingsNav instanceof HTMLElement) {
231
777
  projectSettingsNav.classList.toggle('hidden', !state.pageAccess.settings);
@@ -253,6 +799,12 @@ function applyReadonlyMode() {
253
799
  closeQuickChatPopover();
254
800
  }
255
801
  applySandboxChatVisibility();
802
+ if (aserRuntimeView) {
803
+ aserRuntimeView.setAccessContext({
804
+ readonly: state.readonly,
805
+ hasAccess: state.fullAccess || state.pageAccess.sandboxes,
806
+ });
807
+ }
256
808
  }
257
809
 
258
810
  function getSandboxLayoutElement() {
@@ -828,6 +1380,25 @@ function populateParentSelect(items, preferredParentId = null) {
828
1380
  select.value = hasExpectedValue ? expectedValue : '';
829
1381
  }
830
1382
 
1383
+ function getTodoMetaFromItem(item) {
1384
+ const extraData = item?.extra_data || {};
1385
+ const todo = extraData?.todo && typeof extraData.todo === 'object' ? extraData.todo : {};
1386
+ const plannedStartDate = String(todo.planned_start_date || '').trim();
1387
+ const dueDate = String(todo.due_date || '').trim();
1388
+ return { plannedStartDate, dueDate };
1389
+ }
1390
+
1391
+ function isTodoItem(item) {
1392
+ return Boolean(item?.extra_data?.todo?.is_todo);
1393
+ }
1394
+
1395
+ function getNodeTodoItems(nodeId) {
1396
+ const items = state.currentSandbox?.items || [];
1397
+ return items
1398
+ .filter((item) => String(item.parent_id || '') === String(nodeId || '') && isTodoItem(item))
1399
+ .sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
1400
+ }
1401
+
831
1402
  function normalizeAssistantResponseText(raw) {
832
1403
  let text = String(raw || '').trim();
833
1404
  if (!text) return '';
@@ -1202,12 +1773,14 @@ function renderNodeEntitySummary(nodeId) {
1202
1773
  const issues = rows.filter((row) => row.entity_type === 'issue');
1203
1774
  const knowledges = rows.filter((row) => row.entity_type === 'knowledge');
1204
1775
  const capabilities = rows.filter((row) => row.entity_type === 'capability');
1776
+ const todos = getNodeTodoItems(nodeId);
1205
1777
  const openIssues = issues.filter((row) => row.status === 'open' || row.status === 'in_progress' || row.status === 'blocked').length;
1206
1778
  container.innerHTML = `
1207
1779
  <div class="summary-card"><div class="label">Issue</div><div class="value">${issues.length}</div></div>
1208
1780
  <div class="summary-card"><div class="label">Open Issue</div><div class="value">${openIssues}</div></div>
1209
1781
  <div class="summary-card"><div class="label">Knowledge</div><div class="value">${knowledges.length}</div></div>
1210
1782
  <div class="summary-card"><div class="label">Capability</div><div class="value">${capabilities.length}</div></div>
1783
+ <div class="summary-card"><div class="label">Todo</div><div class="value">${todos.length}</div></div>
1211
1784
  <div class="summary-card"><div class="label">Diary</div><div class="value">${diaries.length}</div></div>
1212
1785
  `;
1213
1786
  }
@@ -1232,8 +1805,10 @@ function renderNodeEntityList(nodeId) {
1232
1805
  if (!container) return;
1233
1806
  const allEntityRows = getNodeEntitiesByNodeId(nodeId);
1234
1807
  const allDiaryRows = getNodeDiariesByNodeId(nodeId);
1808
+ const allTodoRows = getNodeTodoItems(nodeId);
1235
1809
  const timelineRows = [
1236
1810
  ...allEntityRows.map((row) => ({ ...row, timeline_type: row.entity_type || 'issue' })),
1811
+ ...allTodoRows.map((row) => ({ ...row, timeline_type: 'todo' })),
1237
1812
  ...allDiaryRows.map((row) => ({ ...row, timeline_type: 'diary' })),
1238
1813
  ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
1239
1814
  const rows = state.nodeEntityFilter === 'all'
@@ -1270,6 +1845,32 @@ function renderNodeEntityList(nodeId) {
1270
1845
  </div>
1271
1846
  `;
1272
1847
  }
1848
+ if (row.timeline_type === 'todo') {
1849
+ const todoMeta = getTodoMetaFromItem(row);
1850
+ return `
1851
+ <div class="entity-card">
1852
+ <div class="entity-card-header">
1853
+ <div>
1854
+ <span class="entity-type-pill">todo</span>
1855
+ <strong>${safeText(row.name || '-')}</strong>
1856
+ </div>
1857
+ ${state.readonly ? '' : `
1858
+ <div class="entity-card-actions">
1859
+ <button class="btn btn-secondary btn-sm" data-todo-edit-id="${safeText(row.id)}">编辑</button>
1860
+ <button class="btn btn-secondary btn-sm" data-todo-delete-id="${safeText(row.id)}">删除</button>
1861
+ </div>
1862
+ `}
1863
+ </div>
1864
+ <div class="entity-meta">
1865
+ ${safeText(new Date(row.created_at).toLocaleString())}
1866
+ ${row.status ? ` · <span class="entity-status-pill ${safeText(row.status)}">${safeText(row.status)}</span>` : ''}
1867
+ ${row.assignee ? ` · @${safeText(row.assignee)}` : ''}
1868
+ ${todoMeta.plannedStartDate ? ` · 启动 ${safeText(todoMeta.plannedStartDate)}` : ''}
1869
+ ${todoMeta.dueDate ? ` · 截止 ${safeText(todoMeta.dueDate)}` : ''}
1870
+ </div>
1871
+ </div>
1872
+ `;
1873
+ }
1273
1874
  return `
1274
1875
  <div class="entity-card">
1275
1876
  <div class="entity-card-header">
@@ -1289,6 +1890,7 @@ function renderNodeEntityList(nodeId) {
1289
1890
  ${row.status ? ` · <span class="entity-status-pill ${safeText(row.status)}">${safeText(row.status)}</span>` : ''}
1290
1891
  ${row.priority ? ` · ${safeText(row.priority)}` : ''}
1291
1892
  ${row.assignee ? ` · @${safeText(row.assignee)}` : ''}
1893
+ ${row.due_date ? ` · 截止 ${safeText(row.due_date)}` : ''}
1292
1894
  </div>
1293
1895
  <div class="entity-content">${renderMarkdownSnippet(row.content_md || '')}</div>
1294
1896
  </div>
@@ -1316,6 +1918,41 @@ function renderNodeEntityList(nodeId) {
1316
1918
  });
1317
1919
  });
1318
1920
 
1921
+ container.querySelectorAll('[data-todo-edit-id]').forEach((el) => {
1922
+ el.addEventListener('click', (e) => {
1923
+ e.preventDefault();
1924
+ const id = el.getAttribute('data-todo-edit-id');
1925
+ if (!id) return;
1926
+ const row = allTodoRows.find((item) => item.id === id);
1927
+ if (!row) return;
1928
+ startEditNodeEntity({
1929
+ id: row.id,
1930
+ entity_type: 'todo',
1931
+ title: row.name,
1932
+ content_md: row.description || '',
1933
+ assignee: row.assignee || '',
1934
+ status: row.status || 'pending',
1935
+ priority: row.priority || 'medium',
1936
+ capability_type: '',
1937
+ due_date: '',
1938
+ todo_planned_start_date: getTodoMetaFromItem(row).plannedStartDate,
1939
+ todo_due_date: getTodoMetaFromItem(row).dueDate,
1940
+ });
1941
+ });
1942
+ });
1943
+ container.querySelectorAll('[data-todo-delete-id]').forEach((el) => {
1944
+ el.addEventListener('click', async (e) => {
1945
+ e.preventDefault();
1946
+ if (!state.currentSandbox) return;
1947
+ const id = String(el.getAttribute('data-todo-delete-id') || '');
1948
+ if (!id) return;
1949
+ if (!confirm('确定删除该待办?')) return;
1950
+ await apiRequest(`${API_BASE}/items/${id}`, { method: 'DELETE' });
1951
+ await loadSandbox(state.currentSandbox.id);
1952
+ if (state.selectedNodeId) showNodeEntityDrawer(state.selectedNodeId, state.nodeEntityFilter);
1953
+ });
1954
+ });
1955
+
1319
1956
  container.querySelectorAll('[data-diary-edit-id]').forEach((el) => {
1320
1957
  el.addEventListener('click', (e) => {
1321
1958
  e.preventDefault();
@@ -1352,6 +1989,7 @@ function resetNodeEntityForm() {
1352
1989
  const titleInput = document.getElementById('entity-title-input');
1353
1990
  const contentInput = document.getElementById('entity-content-input');
1354
1991
  const assigneeInput = document.getElementById('entity-assignee-input');
1992
+ const dueDateInput = document.getElementById('entity-due-date-input');
1355
1993
  const statusInput = document.getElementById('entity-status-select');
1356
1994
  const priorityInput = document.getElementById('entity-priority-select');
1357
1995
  const capabilityTypeInput = document.getElementById('entity-capability-type-input');
@@ -1359,6 +1997,11 @@ function resetNodeEntityForm() {
1359
1997
  if (titleInput) titleInput.value = '';
1360
1998
  if (contentInput) contentInput.value = '';
1361
1999
  if (assigneeInput) assigneeInput.value = '';
2000
+ if (dueDateInput) dueDateInput.value = '';
2001
+ const todoPlannedStartInput = document.getElementById('entity-todo-planned-start-input');
2002
+ const todoDueDateInput = document.getElementById('entity-todo-due-date-input');
2003
+ if (todoPlannedStartInput) todoPlannedStartInput.value = '';
2004
+ if (todoDueDateInput) todoDueDateInput.value = '';
1362
2005
  if (statusInput) statusInput.value = '';
1363
2006
  if (priorityInput) priorityInput.value = '';
1364
2007
  if (capabilityTypeInput) capabilityTypeInput.value = '';
@@ -1385,20 +2028,27 @@ function ensureCapabilityTypeOption(value) {
1385
2028
  }
1386
2029
 
1387
2030
  function startEditNodeEntity(row) {
1388
- state.editingNodeEntityId = row.id;
2031
+ const rowType = String(row.entity_type || 'issue');
2032
+ state.editingNodeEntityId = rowType === 'todo' ? `todo:${row.id}` : row.id;
1389
2033
  setNodeEntityFormExpanded(true);
1390
2034
  const typeInput = document.getElementById('entity-type-select');
1391
2035
  const titleInput = document.getElementById('entity-title-input');
1392
2036
  const contentInput = document.getElementById('entity-content-input');
1393
2037
  const assigneeInput = document.getElementById('entity-assignee-input');
2038
+ const dueDateInput = document.getElementById('entity-due-date-input');
1394
2039
  const statusInput = document.getElementById('entity-status-select');
1395
2040
  const priorityInput = document.getElementById('entity-priority-select');
1396
2041
  const capabilityTypeInput = document.getElementById('entity-capability-type-input');
1397
- if (typeInput) typeInput.value = row.entity_type || 'issue';
2042
+ if (typeInput) typeInput.value = rowType || 'todo';
1398
2043
  applyEntityFormMode();
1399
- if (titleInput) titleInput.value = row.title || '';
2044
+ if (titleInput) titleInput.value = rowType === 'todo' ? (row.title || row.name || '') : (row.title || '');
1400
2045
  if (contentInput) contentInput.value = row.content_md || '';
1401
2046
  if (assigneeInput) assigneeInput.value = row.assignee || '';
2047
+ if (dueDateInput) dueDateInput.value = row.due_date || '';
2048
+ const todoPlannedStartInput = document.getElementById('entity-todo-planned-start-input');
2049
+ const todoDueDateInput = document.getElementById('entity-todo-due-date-input');
2050
+ if (todoPlannedStartInput) todoPlannedStartInput.value = row.todo_planned_start_date || '';
2051
+ if (todoDueDateInput) todoDueDateInput.value = row.todo_due_date || '';
1402
2052
  if (statusInput) statusInput.value = row.status || '';
1403
2053
  if (priorityInput) priorityInput.value = row.priority || '';
1404
2054
  ensureCapabilityTypeOption(row.capability_type || '');
@@ -1417,7 +2067,7 @@ function showNodeEntityDrawer(nodeId, preferredFilter = 'all') {
1417
2067
  const node = getNodeById(nodeId);
1418
2068
  if (!drawer || !title || !node) return;
1419
2069
  state.selectedNodeId = nodeId;
1420
- const filter = ['all', 'issue', 'knowledge', 'capability', 'diary'].includes(preferredFilter) ? preferredFilter : 'all';
2070
+ const filter = ['all', 'todo', 'issue', 'knowledge', 'capability', 'diary'].includes(preferredFilter) ? preferredFilter : 'all';
1421
2071
  state.nodeEntityFilter = filter;
1422
2072
  title.textContent = node.name || nodeId;
1423
2073
  renderNodeEntitySummary(nodeId);
@@ -1440,6 +2090,14 @@ function renderNodeEntityFilterTabs() {
1440
2090
  }
1441
2091
 
1442
2092
  function getStatusOptionsByEntityType(entityType) {
2093
+ if (entityType === 'todo') {
2094
+ return [
2095
+ { value: 'pending', label: 'pending' },
2096
+ { value: 'in_progress', label: 'in_progress' },
2097
+ { value: 'done', label: 'done' },
2098
+ { value: 'archived', label: 'archived' },
2099
+ ];
2100
+ }
1443
2101
  if (entityType === 'issue') {
1444
2102
  return [
1445
2103
  { value: '', label: '状态(Issue,默认 open)' },
@@ -1463,11 +2121,14 @@ function getStatusOptionsByEntityType(entityType) {
1463
2121
  function applyEntityFormMode() {
1464
2122
  const type = document.getElementById('entity-type-select')?.value || 'issue';
1465
2123
  const assigneeGroup = document.getElementById('entity-assignee-group');
2124
+ const dueDateGroup = document.getElementById('entity-due-date-group');
2125
+ const todoDateGroup = document.getElementById('entity-todo-date-group');
1466
2126
  const statusGroup = document.getElementById('entity-status-group');
1467
2127
  const priorityGroup = document.getElementById('entity-priority-group');
1468
2128
  const capabilityTypeGroup = document.getElementById('entity-capability-type-group');
1469
2129
  const hint = document.getElementById('entity-form-hint');
1470
2130
  const statusSelect = document.getElementById('entity-status-select');
2131
+ const contentInput = document.getElementById('entity-content-input');
1471
2132
 
1472
2133
  if (statusSelect) {
1473
2134
  statusSelect.innerHTML = getStatusOptionsByEntityType(type)
@@ -1475,17 +2136,22 @@ function applyEntityFormMode() {
1475
2136
  .join('');
1476
2137
  }
1477
2138
 
1478
- assigneeGroup?.classList.toggle('hidden', type !== 'issue');
1479
- priorityGroup?.classList.toggle('hidden', type !== 'issue');
2139
+ assigneeGroup?.classList.toggle('hidden', !(type === 'issue' || type === 'todo'));
2140
+ dueDateGroup?.classList.toggle('hidden', type !== 'issue');
2141
+ todoDateGroup?.classList.toggle('hidden', type !== 'todo');
2142
+ priorityGroup?.classList.toggle('hidden', !(type === 'issue' || type === 'todo'));
1480
2143
  capabilityTypeGroup?.classList.toggle('hidden', type !== 'capability');
1481
2144
  statusGroup?.classList.toggle('hidden', false);
2145
+ contentInput?.classList.toggle('hidden', type === 'todo');
1482
2146
 
1483
2147
  if (hint) {
1484
- hint.textContent = type === 'issue'
1485
- ? 'Issue 推荐填写状态/优先级/负责人。'
1486
- : type === 'knowledge'
1487
- ? 'Knowledge 可仅保存链接或少量 Markdown。'
1488
- : 'Capability 建议填写能力类型与简述。';
2148
+ hint.textContent = type === 'todo'
2149
+ ? 'Todo 将作为当前节点子任务创建。'
2150
+ : type === 'issue'
2151
+ ? 'Issue 推荐填写状态/优先级/负责人。'
2152
+ : type === 'knowledge'
2153
+ ? 'Knowledge 可仅保存链接或少量 Markdown。'
2154
+ : 'Capability 建议填写能力类型与简述。';
1489
2155
  }
1490
2156
  }
1491
2157
 
@@ -1774,6 +2440,78 @@ function renderSandboxes() {
1774
2440
  });
1775
2441
  }
1776
2442
 
2443
+ function renderTaskBoardList(containerId, rows = []) {
2444
+ const container = document.getElementById(containerId);
2445
+ if (!(container instanceof HTMLElement)) return;
2446
+ if (!rows.length) {
2447
+ container.innerHTML = '<div class="empty-state"><p>暂无任务</p></div>';
2448
+ return;
2449
+ }
2450
+ container.innerHTML = rows.map((row) => `
2451
+ <article class="task-board-item">
2452
+ <div><strong>${safeText(row.name || '-')}</strong> <span class="entity-type-pill">${safeText(row.record_type || 'todo')}</span></div>
2453
+ <div class="task-board-item-meta">沙盘:${safeText(row.sandbox_name || row.sandbox_id || '-')}</div>
2454
+ <div class="task-board-item-meta">负责人:${safeText(row.assignee || '我(未指派)')} · 状态:${safeText(row.status || '-')}</div>
2455
+ <div class="task-board-item-meta">计划启动:${safeText(row.planned_start_date || '-')} · 截止:${safeText(row.due_date || '-')}</div>
2456
+ </article>
2457
+ `).join('');
2458
+ }
2459
+
2460
+ function normalizeTaskBoardPeriod(value) {
2461
+ const normalized = String(value || '').trim();
2462
+ if (['week', 'month', 'quarter', 'all'].includes(normalized)) {
2463
+ return normalized;
2464
+ }
2465
+ return 'week';
2466
+ }
2467
+
2468
+ function getTaskBoardRangeLabel(taskBoardData) {
2469
+ const period = normalizeTaskBoardPeriod(taskBoardData?.period || state.taskBoardPeriod);
2470
+ if (period === 'all') return '全部';
2471
+ return `${safeText(taskBoardData?.start_date || '-')} ~ ${safeText(taskBoardData?.end_date || '-')}`;
2472
+ }
2473
+
2474
+ function renderTaskBoard() {
2475
+ const summary = document.getElementById('task-board-summary');
2476
+ const periodSelect = document.getElementById('task-board-period');
2477
+ const ownerInput = document.getElementById('task-board-owner-input');
2478
+ if (periodSelect instanceof HTMLSelectElement) {
2479
+ periodSelect.value = normalizeTaskBoardPeriod(state.taskBoardPeriod);
2480
+ }
2481
+ if (ownerInput instanceof HTMLInputElement) {
2482
+ ownerInput.value = state.taskBoardOwnerName || '';
2483
+ }
2484
+ if (!state.taskBoardData) {
2485
+ renderTaskBoardList('task-board-mine-list', []);
2486
+ renderTaskBoardList('task-board-others-list', []);
2487
+ if (summary instanceof HTMLElement) {
2488
+ summary.innerHTML = '';
2489
+ }
2490
+ return;
2491
+ }
2492
+ renderTaskBoardList('task-board-mine-list', state.taskBoardData.mine || []);
2493
+ renderTaskBoardList('task-board-others-list', state.taskBoardData.others || []);
2494
+ if (summary instanceof HTMLElement) {
2495
+ const counts = state.taskBoardData.counts || { mine: 0, others: 0, total: 0 };
2496
+ summary.innerHTML = `
2497
+ <div class="summary-card"><div class="label">统计区间</div><div class="value" style="font-size:13px;font-weight:500">${getTaskBoardRangeLabel(state.taskBoardData)}</div></div>
2498
+ <div class="summary-card"><div class="label">我的任务</div><div class="value">${Number(counts.mine || 0)}</div></div>
2499
+ <div class="summary-card"><div class="label">他人任务</div><div class="value">${Number(counts.others || 0)}</div></div>
2500
+ <div class="summary-card"><div class="label">总数</div><div class="value">${Number(counts.total || 0)}</div></div>
2501
+ `;
2502
+ }
2503
+ }
2504
+
2505
+ async function loadTaskBoard() {
2506
+ const params = new URLSearchParams();
2507
+ params.set('period', normalizeTaskBoardPeriod(state.taskBoardPeriod));
2508
+ if (state.taskBoardOwnerName) {
2509
+ params.set('owner', state.taskBoardOwnerName);
2510
+ }
2511
+ state.taskBoardData = await apiRequest(`${API_BASE}/items/agenda?${params.toString()}`);
2512
+ renderTaskBoard();
2513
+ }
2514
+
1777
2515
  async function loadSandbox(id) {
1778
2516
  closeQuickChatPopover();
1779
2517
  state.currentSandboxWritable = canWriteSandboxById(id);
@@ -2606,6 +3344,20 @@ function showPage(pageId) {
2606
3344
  const navPageId = pageId === 'sandbox-detail' ? 'sandboxes' : pageId;
2607
3345
  const nav = document.querySelector(`[data-nav="${navPageId}"]`);
2608
3346
  if (nav) nav.classList.add('active');
3347
+ if (['ai-engineering', 'aser-runtime', 'agent-club', 'teams', 'opencode-team-runner'].includes(navPageId)) {
3348
+ setAiEngineeringNavCollapsed(false);
3349
+ }
3350
+ }
3351
+
3352
+ function setAiEngineeringNavCollapsed(collapsed) {
3353
+ const navGroup = document.getElementById('ai-engineering-nav-group');
3354
+ const toggleBtn = document.getElementById('toggle-ai-engineering-nav');
3355
+ if (!(navGroup instanceof HTMLElement)) return;
3356
+ navGroup.classList.toggle('nav-group-collapsed', Boolean(collapsed));
3357
+ if (toggleBtn instanceof HTMLButtonElement) {
3358
+ toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
3359
+ toggleBtn.textContent = collapsed ? '▸' : '▾';
3360
+ }
2609
3361
  }
2610
3362
 
2611
3363
  function editWorkItem(id) {
@@ -2629,8 +3381,18 @@ async function initApp() {
2629
3381
  loadWorkItemAssigneePreference();
2630
3382
  loadWorkTreeViewModePreference();
2631
3383
  loadSandboxSortModePreference();
3384
+ loadTaskBoardOwnerNamePreference();
3385
+ loadAiEngineeringState();
2632
3386
  renderQuickDiaryTargetLabel();
2633
3387
  applyReadonlyMode();
3388
+ aserRuntimeView = createAserRuntimeView({
3389
+ readonly: state.readonly,
3390
+ hasAccess: state.fullAccess || state.pageAccess.sandboxes,
3391
+ getTeams: () => state.teams.map((team) => ({ ...team })),
3392
+ getProjectTeamAssignments: () => ({ ...(state.aserProjectTeamAssignments || {}) }),
3393
+ assignProjectTeam: (projectId, teamId) => assignAserProjectTeam(projectId, teamId),
3394
+ });
3395
+ aserRuntimeView.mount();
2634
3396
 
2635
3397
  document.querySelectorAll('.nav-list a').forEach(link => {
2636
3398
  link.addEventListener('click', (e) => {
@@ -2638,6 +3400,152 @@ async function initApp() {
2638
3400
  window.location.hash = link.getAttribute('href').replace('#', '') || '/';
2639
3401
  });
2640
3402
  });
3403
+
3404
+ document.getElementById('agent-club-create-btn')?.addEventListener('click', () => {
3405
+ const nameInput = document.getElementById('agent-club-name');
3406
+ const roleInput = document.getElementById('agent-club-role');
3407
+ const knowledgeInput = document.getElementById('agent-club-knowledge');
3408
+ const skillsInput = document.getElementById('agent-club-skills');
3409
+ const taskTypesInput = document.getElementById('agent-club-task-types');
3410
+ const deliverablesInput = document.getElementById('agent-club-deliverables');
3411
+ const raciInput = document.getElementById('agent-club-raci-default');
3412
+ const qualityInput = document.getElementById('agent-club-quality-criteria');
3413
+ const riskInput = document.getElementById('agent-club-risk-limits');
3414
+ const name = String(nameInput?.value || '').trim();
3415
+ const role = String(roleInput?.value || '').trim();
3416
+ const knowledgeBackground = parseLineList(knowledgeInput?.value);
3417
+ const skills = parseSkillRows(skillsInput?.value);
3418
+ const taskTypes = parseLineList(taskTypesInput?.value);
3419
+ const deliverables = parseLineList(deliverablesInput?.value);
3420
+ const raciDefault = String(raciInput?.value || '').trim();
3421
+ const qualityCriteria = parseLineList(qualityInput?.value);
3422
+ const riskLimits = parseLineList(riskInput?.value);
3423
+ if (!name) {
3424
+ alert('请填写 Agent 名称');
3425
+ return;
3426
+ }
3427
+ const id = `agent-${Date.now()}`;
3428
+ state.agentClub.unshift(normalizeAgentRecord({
3429
+ id,
3430
+ name,
3431
+ role_type: role || 'generalist',
3432
+ knowledge_background: knowledgeBackground,
3433
+ skills,
3434
+ task_types: taskTypes,
3435
+ deliverables,
3436
+ raci_default: raciDefault,
3437
+ quality_criteria: qualityCriteria,
3438
+ risk_limits: riskLimits,
3439
+ }));
3440
+ persistAiEngineeringState();
3441
+ renderAgentClub();
3442
+ renderTeams();
3443
+ renderOpenCodeRunner();
3444
+ if (nameInput) nameInput.value = '';
3445
+ if (roleInput) roleInput.value = '';
3446
+ if (knowledgeInput) knowledgeInput.value = '';
3447
+ if (skillsInput) skillsInput.value = '';
3448
+ if (taskTypesInput) taskTypesInput.value = '';
3449
+ if (deliverablesInput) deliverablesInput.value = '';
3450
+ if (raciInput) raciInput.value = '';
3451
+ if (qualityInput) qualityInput.value = '';
3452
+ if (riskInput) riskInput.value = '';
3453
+ if (aserRuntimeView) aserRuntimeView.refreshTeamContext();
3454
+ });
3455
+
3456
+ document.getElementById('agent-club-list')?.addEventListener('click', (event) => {
3457
+ const target = event.target;
3458
+ if (!(target instanceof HTMLElement)) return;
3459
+ const deleteId = String(target.getAttribute('data-agent-delete-id') || '').trim();
3460
+ if (!deleteId) return;
3461
+ state.agentClub = state.agentClub.filter((agent) => String(agent.id) !== deleteId);
3462
+ state.teams = state.teams.map((team) => ({
3463
+ ...team,
3464
+ agent_ids: team.agent_ids.filter((agentId) => String(agentId) !== deleteId),
3465
+ }));
3466
+ persistAiEngineeringState();
3467
+ renderAgentClub();
3468
+ renderTeams();
3469
+ renderOpenCodeRunner();
3470
+ if (aserRuntimeView) aserRuntimeView.refreshTeamContext();
3471
+ });
3472
+
3473
+ document.getElementById('teams-create-btn')?.addEventListener('click', () => {
3474
+ const nameInput = document.getElementById('teams-name');
3475
+ const agentSelect = document.getElementById('teams-agent-select');
3476
+ const name = String(nameInput?.value || '').trim();
3477
+ if (!name) {
3478
+ alert('请填写 Team 名称');
3479
+ return;
3480
+ }
3481
+ const agentIds = agentSelect instanceof HTMLSelectElement
3482
+ ? Array.from(agentSelect.selectedOptions)
3483
+ .map((option) => String(option.value || '').trim())
3484
+ .filter(Boolean)
3485
+ .filter((agentId) => state.agentClub.some((agent) => String(agent.id) === agentId))
3486
+ : [];
3487
+ const id = `team-${Date.now()}`;
3488
+ state.teams.unshift(normalizeTeamRecord({ id, name, agent_ids: agentIds }));
3489
+ persistAiEngineeringState();
3490
+ renderTeams();
3491
+ renderOpenCodeRunner();
3492
+ if (nameInput) nameInput.value = '';
3493
+ if (agentSelect instanceof HTMLSelectElement) {
3494
+ Array.from(agentSelect.options).forEach((option) => {
3495
+ option.selected = false;
3496
+ });
3497
+ }
3498
+ if (aserRuntimeView) aserRuntimeView.refreshTeamContext();
3499
+ });
3500
+
3501
+ document.getElementById('teams-list')?.addEventListener('click', (event) => {
3502
+ const target = event.target;
3503
+ if (!(target instanceof HTMLElement)) return;
3504
+ const deleteId = String(target.getAttribute('data-team-delete-id') || '').trim();
3505
+ if (!deleteId) return;
3506
+ state.teams = state.teams.filter((team) => String(team.id) !== deleteId);
3507
+ Object.keys(state.aserProjectTeamAssignments || {}).forEach((projectId) => {
3508
+ if (String(state.aserProjectTeamAssignments[projectId]) === deleteId) {
3509
+ delete state.aserProjectTeamAssignments[projectId];
3510
+ }
3511
+ });
3512
+ persistAiEngineeringState();
3513
+ renderTeams();
3514
+ renderOpenCodeRunner();
3515
+ if (aserRuntimeView) aserRuntimeView.refreshTeamContext();
3516
+ });
3517
+
3518
+ document.getElementById('opencode-runner-project-select')?.addEventListener('change', (event) => {
3519
+ const target = event.target;
3520
+ if (!(target instanceof HTMLSelectElement)) return;
3521
+ state.opencodeRunnerSelectedProjectId = String(target.value || '').trim();
3522
+ const assigned = getAssignedTeamIdForProject(state.opencodeRunnerSelectedProjectId);
3523
+ if (assigned) state.opencodeRunnerSelectedTeamId = assigned;
3524
+ renderOpenCodeRunner();
3525
+ });
3526
+
3527
+ document.getElementById('opencode-runner-team-select')?.addEventListener('change', (event) => {
3528
+ const target = event.target;
3529
+ if (!(target instanceof HTMLSelectElement)) return;
3530
+ state.opencodeRunnerSelectedTeamId = String(target.value || '').trim();
3531
+ renderOpenCodeRunner();
3532
+ });
3533
+
3534
+ document.getElementById('opencode-runner-load-btn')?.addEventListener('click', () => {
3535
+ loadOpenCodeRunnerTasks().catch((error) => {
3536
+ alert(`获取任务失败: ${error?.message || error}`);
3537
+ });
3538
+ });
3539
+
3540
+ document.getElementById('opencode-runner-plan-btn')?.addEventListener('click', () => {
3541
+ planOpenCodeRunnerTasks();
3542
+ });
3543
+
3544
+ document.getElementById('opencode-runner-start-btn')?.addEventListener('click', () => {
3545
+ startOpenCodeRunner().catch((error) => {
3546
+ alert(`启动 Runner 失败: ${error?.message || error}`);
3547
+ });
3548
+ });
2641
3549
 
2642
3550
  document.getElementById('add-sandbox-btn')?.addEventListener('click', () => {
2643
3551
  if (state.readonly) return;
@@ -2772,13 +3680,49 @@ async function initApp() {
2772
3680
  document.getElementById('item-dialog').close();
2773
3681
  });
2774
3682
 
3683
+ document.getElementById('toggle-ai-engineering-nav')?.addEventListener('click', () => {
3684
+ const navGroup = document.getElementById('ai-engineering-nav-group');
3685
+ if (!(navGroup instanceof HTMLElement)) return;
3686
+ const collapsed = navGroup.classList.contains('nav-group-collapsed');
3687
+ setAiEngineeringNavCollapsed(!collapsed);
3688
+ });
3689
+
3690
+ document.getElementById('task-board-period')?.addEventListener('change', async (event) => {
3691
+ const value = String(event?.target?.value || 'week');
3692
+ state.taskBoardPeriod = normalizeTaskBoardPeriod(value);
3693
+ await loadTaskBoard();
3694
+ });
3695
+
3696
+ document.getElementById('task-board-owner-input')?.addEventListener('change', async (event) => {
3697
+ state.taskBoardOwnerName = String(event?.target?.value || '').trim();
3698
+ persistTaskBoardOwnerNamePreference();
3699
+ await loadTaskBoard();
3700
+ });
3701
+
3702
+ document.getElementById('task-board-refresh-btn')?.addEventListener('click', async () => {
3703
+ await loadTaskBoard();
3704
+ });
3705
+
2775
3706
  document.getElementById('close-node-drawer-btn')?.addEventListener('click', () => {
2776
3707
  closeNodeEntityDrawer();
2777
3708
  });
2778
3709
 
2779
3710
  document.getElementById('toggle-node-entity-form-btn')?.addEventListener('click', () => {
2780
3711
  if (state.readonly) return;
2781
- setNodeEntityFormExpanded(!state.nodeEntityFormExpanded);
3712
+ const nextExpanded = !state.nodeEntityFormExpanded;
3713
+ if (nextExpanded) {
3714
+ const preferredType = ['todo', 'issue', 'knowledge', 'capability'].includes(state.nodeEntityFilter)
3715
+ ? state.nodeEntityFilter
3716
+ : 'issue';
3717
+ if (!state.editingNodeEntityId) {
3718
+ const typeSelect = document.getElementById('entity-type-select');
3719
+ if (typeSelect instanceof HTMLSelectElement) {
3720
+ typeSelect.value = preferredType;
3721
+ applyEntityFormMode();
3722
+ }
3723
+ }
3724
+ }
3725
+ setNodeEntityFormExpanded(nextExpanded);
2782
3726
  if (state.nodeEntityFormExpanded) {
2783
3727
  document.getElementById('entity-title-input')?.focus();
2784
3728
  }
@@ -2813,31 +3757,77 @@ async function initApp() {
2813
3757
  const entity_type = document.getElementById('entity-type-select').value;
2814
3758
  if (!title) return;
2815
3759
  const rawStatus = document.getElementById('entity-status-select').value || '';
2816
- const status = rawStatus || (entity_type === 'issue' ? 'open' : entity_type === 'capability' ? 'building' : '');
2817
- const payload = {
2818
- work_item_id: state.selectedNodeId,
2819
- entity_type,
2820
- title,
2821
- content_md: document.getElementById('entity-content-input').value || '',
2822
- assignee: document.getElementById('entity-assignee-input').value || '',
2823
- status,
2824
- priority: document.getElementById('entity-priority-select').value || '',
2825
- capability_type: document.getElementById('entity-capability-type-input').value || '',
2826
- };
2827
-
3760
+ const status = rawStatus || (entity_type === 'todo'
3761
+ ? 'pending'
3762
+ : entity_type === 'issue'
3763
+ ? 'open'
3764
+ : entity_type === 'capability'
3765
+ ? 'building'
3766
+ : '');
2828
3767
  const isEditing = Boolean(state.editingNodeEntityId);
3768
+ const editingTodoId = isEditing && String(state.editingNodeEntityId).startsWith('todo:')
3769
+ ? String(state.editingNodeEntityId).slice(5)
3770
+ : '';
3771
+ const editingEntityId = isEditing && !editingTodoId ? String(state.editingNodeEntityId) : '';
3772
+
2829
3773
  setButtonState(btn, { disabled: true, text: isEditing ? '保存中...' : '添加中...' });
2830
3774
  try {
2831
- if (isEditing) {
2832
- await apiRequest(`${API_BASE}/sandboxes/${state.currentSandbox.id}/entities/${state.editingNodeEntityId}`, {
2833
- method: 'PUT',
2834
- body: JSON.stringify(payload),
2835
- });
3775
+ if (entity_type === 'todo') {
3776
+ const todoPayload = {
3777
+ name: title,
3778
+ parent_id: state.selectedNodeId,
3779
+ description: '',
3780
+ assignee: document.getElementById('entity-assignee-input').value || '',
3781
+ status,
3782
+ priority: document.getElementById('entity-priority-select').value || 'medium',
3783
+ extra_data: {
3784
+ todo: {
3785
+ is_todo: true,
3786
+ planned_start_date: document.getElementById('entity-todo-planned-start-input').value || '',
3787
+ due_date: document.getElementById('entity-todo-due-date-input').value || '',
3788
+ },
3789
+ },
3790
+ };
3791
+ const plannedStart = String(todoPayload.extra_data.todo.planned_start_date || '').trim();
3792
+ const dueDate = String(todoPayload.extra_data.todo.due_date || '').trim();
3793
+ if (plannedStart && dueDate && plannedStart > dueDate) {
3794
+ alert('计划启动日期不能晚于截止日期。');
3795
+ return;
3796
+ }
3797
+ if (editingTodoId) {
3798
+ await apiRequest(`${API_BASE}/items/${editingTodoId}`, {
3799
+ method: 'PUT',
3800
+ body: JSON.stringify(todoPayload),
3801
+ });
3802
+ } else {
3803
+ await apiRequest(`${API_BASE}/sandboxes/${state.currentSandbox.id}/items`, {
3804
+ method: 'POST',
3805
+ body: JSON.stringify(todoPayload),
3806
+ });
3807
+ }
2836
3808
  } else {
2837
- await apiRequest(`${API_BASE}/sandboxes/${state.currentSandbox.id}/entities`, {
2838
- method: 'POST',
2839
- body: JSON.stringify(payload),
2840
- });
3809
+ const payload = {
3810
+ work_item_id: state.selectedNodeId,
3811
+ entity_type,
3812
+ title,
3813
+ content_md: document.getElementById('entity-content-input').value || '',
3814
+ assignee: document.getElementById('entity-assignee-input').value || '',
3815
+ due_date: document.getElementById('entity-due-date-input').value || '',
3816
+ status,
3817
+ priority: document.getElementById('entity-priority-select').value || '',
3818
+ capability_type: document.getElementById('entity-capability-type-input').value || '',
3819
+ };
3820
+ if (editingEntityId) {
3821
+ await apiRequest(`${API_BASE}/sandboxes/${state.currentSandbox.id}/entities/${editingEntityId}`, {
3822
+ method: 'PUT',
3823
+ body: JSON.stringify(payload),
3824
+ });
3825
+ } else {
3826
+ await apiRequest(`${API_BASE}/sandboxes/${state.currentSandbox.id}/entities`, {
3827
+ method: 'POST',
3828
+ body: JSON.stringify(payload),
3829
+ });
3830
+ }
2841
3831
  }
2842
3832
  await loadSandbox(state.currentSandbox.id);
2843
3833
  showNodeEntityDrawer(state.selectedNodeId);
@@ -2908,7 +3898,9 @@ async function initApp() {
2908
3898
  if (state.readonly) return;
2909
3899
  const dialog = document.getElementById('item-dialog');
2910
3900
  const editId = dialog.dataset.editId || null;
2911
- const isNewItem = !editId;
3901
+ const editingItem = editId
3902
+ ? state.currentSandbox?.items?.find((item) => String(item.id) === String(editId))
3903
+ : null;
2912
3904
 
2913
3905
  const data = {
2914
3906
  name: document.getElementById('new-item-name').value,
@@ -2918,6 +3910,10 @@ async function initApp() {
2918
3910
  priority: document.getElementById('new-item-priority').value,
2919
3911
  parent_id: document.getElementById('new-item-parent').value || null,
2920
3912
  };
3913
+ const mergedExtraData = {
3914
+ ...(editingItem?.extra_data || {}),
3915
+ };
3916
+ data.extra_data = mergedExtraData;
2921
3917
 
2922
3918
  if (!data.name) {
2923
3919
  return;
@@ -3574,6 +4570,12 @@ async function initApp() {
3574
4570
  } else if (state.canAccessSystemSettings) {
3575
4571
  window.location.hash = '/system-settings';
3576
4572
  }
4573
+ } else if (pathHash === '/ai-engineering') {
4574
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
4575
+ window.location.hash = '/';
4576
+ return;
4577
+ }
4578
+ showPage('ai-engineering');
3577
4579
  } else if (pathHash === '/sandboxes') {
3578
4580
  if (!state.pageAccess.sandboxes && !state.fullAccess) {
3579
4581
  window.location.hash = '/';
@@ -3603,6 +4605,44 @@ async function initApp() {
3603
4605
  showPage('diaries');
3604
4606
  await loadDiaries();
3605
4607
  await loadSandboxes();
4608
+ } else if (pathHash === '/tasks') {
4609
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
4610
+ window.location.hash = '/';
4611
+ return;
4612
+ }
4613
+ showPage('tasks');
4614
+ await loadTaskBoard();
4615
+ } else if (pathHash === '/aser-runtime') {
4616
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
4617
+ window.location.hash = '/';
4618
+ return;
4619
+ }
4620
+ showPage('aser-runtime');
4621
+ if (aserRuntimeView) {
4622
+ await aserRuntimeView.loadProjects();
4623
+ }
4624
+ } else if (pathHash === '/agent-club') {
4625
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
4626
+ window.location.hash = '/';
4627
+ return;
4628
+ }
4629
+ showPage('agent-club');
4630
+ renderAgentClub();
4631
+ } else if (pathHash === '/teams') {
4632
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
4633
+ window.location.hash = '/';
4634
+ return;
4635
+ }
4636
+ showPage('teams');
4637
+ renderTeams();
4638
+ } else if (pathHash === '/opencode-team-runner') {
4639
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
4640
+ window.location.hash = '/';
4641
+ return;
4642
+ }
4643
+ showPage('opencode-team-runner');
4644
+ await loadOpenCodeRunnerProjects();
4645
+ renderOpenCodeRunner();
3606
4646
  } else if (pathHash === '/changes') {
3607
4647
  if (!state.pageAccess.changes && !state.fullAccess) {
3608
4648
  window.location.hash = '/';