project-knowledge 0.1.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INDEX.md +53 -0
  3. package/README.md +79 -0
  4. package/_site/README.md +63 -0
  5. package/_site/_test/ai-profile-test.js +199 -0
  6. package/_site/_test/baseline-schema-test.js +132 -0
  7. package/_site/_test/commit-analysis-test.js +184 -0
  8. package/_site/_test/context-pack-test.js +199 -0
  9. package/_site/_test/draft-apply-test.js +363 -0
  10. package/_site/_test/git-validation-test.js +171 -0
  11. package/_site/_test/hook-trigger-test.js +257 -0
  12. package/_site/_test/initial-analysis-test.js +228 -0
  13. package/_site/_test/job-orchestrator-test.js +297 -0
  14. package/_site/_test/kb-v2-templates-test.js +189 -0
  15. package/_site/_test/pr-consumer-contract-test.js +236 -0
  16. package/_site/_test/run-all-tests.js +135 -0
  17. package/_site/_test/scanner-test.js +206 -0
  18. package/_site/_test/ui-smoke-test.js +237 -0
  19. package/_site/_test/ui-test.js +237 -0
  20. package/_site/index.html +1166 -0
  21. package/_site/lib/ai-adapter.js +287 -0
  22. package/_site/lib/analysis-orchestrator.js +433 -0
  23. package/_site/lib/context-pack-builder.js +290 -0
  24. package/_site/lib/draft-apply.js +219 -0
  25. package/_site/lib/git-runner.js +26 -0
  26. package/_site/lib/hook-manager.js +148 -0
  27. package/_site/lib/job-orchestrator.js +231 -0
  28. package/_site/lib/kb-validator.js +224 -0
  29. package/_site/lib/llm-client.js +126 -0
  30. package/_site/lib/scanner.js +94 -0
  31. package/_site/scripts/hook-trigger.js +133 -0
  32. package/_site/scripts/safe-runner.js +151 -0
  33. package/_site/server.js +1058 -0
  34. package/_site/start.bat +26 -0
  35. package/_site/stop.bat +11 -0
  36. package/ai-profiles.json +18 -0
  37. package/docs/ai-knowledge-base-system-design.md +395 -0
  38. package/docs/pr-consumer-contract.md +198 -0
  39. package/docs/project-goal.md +72 -0
  40. package/docs/project-registry-schema.md +46 -0
  41. package/docs/testing-strategy.md +169 -0
  42. package/iterations.json +23 -0
  43. package/package.json +47 -0
  44. package/scripts/gen-commit-doc.ps1 +178 -0
  45. package/scripts/gen-commit-doc.sh +197 -0
  46. package/scripts/list-features.ps1 +41 -0
  47. package/scripts/register-scheduled-task.bat +5 -0
  48. package/templates/change.md +59 -0
  49. package/templates/commit-feature.md +56 -0
  50. package/templates/feature.md +44 -0
  51. package/templates/framework.md +80 -0
  52. package/templates/index-header.md +3 -0
  53. package/templates/kb-manifest.json +38 -0
  54. package/templates/module.md +58 -0
  55. package/templates/project-analysis.md +48 -0
  56. package/templates/project-goal.md +55 -0
  57. package/templates/project-readme.md +60 -0
  58. package/templates/quality-review-rules.md +37 -0
  59. package/templates/update-entry.md +7 -0
@@ -0,0 +1,1166 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>KB Control Center</title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <style>
10
+ [v-cloak] { display: none; }
11
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; }
12
+ code, pre, textarea.font-mono, input.font-mono { font-family: "JetBrains Mono", Consolas, monospace; }
13
+ .theme-light {
14
+ --page: #eef2f6; --panel: #ffffff; --panel2: #f7f9fc; --line: #d8e1ea;
15
+ --text: #142033; --muted: #64748b; --side: #101722; --side2: #182334;
16
+ --sideText: #d7e0ea; --accent: #4f6f8f; --accent2: #3e5e75;
17
+ --good: #2f7d64; --warn: #9a7428; --bad: #af4b4b; --idle: #8794a6;
18
+ }
19
+ .theme-dark {
20
+ --page: #0c1118; --panel: #141c26; --panel2: #192331; --line: #2a3849;
21
+ --text: #e3ebf5; --muted: #94a3b8; --side: #080c11; --side2: #111923;
22
+ --sideText: #d7e0ea; --accent: #7492a5; --accent2: #8da9ba;
23
+ --good: #75b798; --warn: #c5a15d; --bad: #d36d6d; --idle: #8794a6;
24
+ }
25
+ .shell { min-height: 100vh; background: var(--page); color: var(--text); }
26
+ .side { background: var(--side); color: var(--sideText); }
27
+ .side2 { background: var(--side2); }
28
+ .panel { background: var(--panel); border-color: var(--line); }
29
+ .panel2 { background: var(--panel2); border-color: var(--line); }
30
+ .muted { color: var(--muted); }
31
+ .line { border-color: var(--line); }
32
+ .btn { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 9px; padding: 8px 11px; font-size: 13px; }
33
+ .btn:hover { filter: brightness(0.98); }
34
+ .btn-primary { border: 1px solid transparent; background: var(--accent); color: white; border-radius: 9px; padding: 8px 11px; font-size: 13px; }
35
+ .btn-primary:hover { background: var(--accent2); }
36
+ .btn-danger { border: 1px solid color-mix(in srgb, var(--bad) 35%, transparent); color: var(--bad); background: color-mix(in srgb, var(--bad) 10%, transparent); border-radius: 9px; padding: 8px 11px; font-size: 13px; }
37
+ .input { background: var(--panel); border: 1px solid var(--line); color: var(--text); border-radius: 9px; padding: 9px 11px; }
38
+ .dot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; }
39
+ .good { background: var(--good); } .warn { background: var(--warn); } .bad { background: var(--bad); } .idle { background: var(--idle); }
40
+ .chip { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; font-size: 11px; color: var(--muted); background: var(--panel2); }
41
+ .scrollbar-thin::-webkit-scrollbar { width: 7px; height: 7px; }
42
+ .scrollbar-thin::-webkit-scrollbar-thumb { background: #73829666; border-radius: 999px; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div id="app" v-cloak>
47
+ <div :class="['shell', theme === 'dark' ? 'theme-dark' : 'theme-light']">
48
+ <div class="grid min-h-screen lg:grid-cols-[310px_1fr]">
49
+ <aside class="side flex min-h-screen flex-col">
50
+ <div class="border-b border-white/10 p-5">
51
+ <div class="text-xs uppercase tracking-[0.18em] text-slate-400">{{ t("appName") }}</div>
52
+ <div class="mt-1 text-xl font-semibold">{{ t("controlCenter") }}</div>
53
+ <div class="mt-2 break-all text-xs text-slate-400">{{ kbRoot || t("connecting") }}</div>
54
+ </div>
55
+
56
+ <nav class="border-b border-white/10 p-3">
57
+ <button v-for="item in navItems" :key="item.key" @click="activeView = item.key"
58
+ :class="['mb-1 flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition',
59
+ activeView === item.key ? 'side2 text-white' : 'text-slate-300 hover:bg-white/5']">
60
+ <span>{{ item.label }}</span>
61
+ <span v-if="item.badge" class="rounded-full bg-white/10 px-2 py-0.5 text-xs text-slate-300">{{ item.badge }}</span>
62
+ </button>
63
+ </nav>
64
+
65
+ <div class="flex min-h-0 flex-1 flex-col p-3">
66
+ <div class="mb-2 flex items-center justify-between px-1">
67
+ <span class="text-xs font-medium uppercase tracking-[0.14em] text-slate-500">{{ t("projects") }}</span>
68
+ <span class="text-xs text-slate-500">{{ projectList.length }}</span>
69
+ </div>
70
+ <div class="min-h-0 flex-1 overflow-auto pr-1 scrollbar-thin">
71
+ <button v-for="p in projectList" :key="p.slug"
72
+ @click="selectProject(p.slug); activeView = 'dashboard'"
73
+ :class="['mb-2 w-full rounded-xl border p-3 text-left transition',
74
+ selectedSlug === p.slug ? 'border-slate-500 bg-white/10' : 'border-white/5 bg-white/[0.03] hover:bg-white/[0.06]']">
75
+ <div class="flex items-start justify-between gap-2">
76
+ <div class="min-w-0">
77
+ <div class="truncate text-sm font-medium text-white">{{ p.cfg.displayName }}</div>
78
+ <div class="mt-0.5 truncate text-xs text-slate-400">{{ p.slug }}</div>
79
+ </div>
80
+ <span :class="['dot mt-1', projectStatusClass(p.slug)]"></span>
81
+ </div>
82
+ <div class="mt-3 flex flex-wrap gap-1">
83
+ <span class="rounded bg-white/8 px-1.5 py-0.5 text-[10px] text-slate-300">{{ p.cfg.repoStatus || "unknown" }}</span>
84
+ <span class="rounded bg-white/8 px-1.5 py-0.5 text-[10px] text-slate-300">{{ p.cfg.lastScanPendingCount || 0 }} pending</span>
85
+ </div>
86
+ </button>
87
+ </div>
88
+ </div>
89
+
90
+ <div class="border-t border-white/10 p-4">
91
+ <button @click="toggleTheme" class="w-full rounded-lg border border-white/10 px-3 py-2 text-sm text-slate-200 hover:bg-white/5">
92
+ {{ theme === "dark" ? t("lightMode") : t("darkMode") }}
93
+ </button>
94
+ </div>
95
+ </aside>
96
+
97
+ <main class="min-w-0 p-5 lg:p-7">
98
+ <header class="mb-5 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
99
+ <div>
100
+ <div class="text-sm muted">{{ t("consoleSubtitle") }}</div>
101
+ <h1 class="mt-1 text-2xl font-semibold">{{ pageTitle }}</h1>
102
+ </div>
103
+ <div class="flex flex-wrap items-center gap-2">
104
+ <span v-if="pollError" class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm" style="color: var(--bad)">{{ pollError }}</span>
105
+ <span v-else-if="loading" class="rounded-lg border px-3 py-2 text-sm panel2 muted">{{ t("refreshing") }}</span>
106
+ <select v-model="uiLanguage" class="input py-2 text-sm">
107
+ <option value="zh">{{ t("chinese") }}</option>
108
+ <option value="en">{{ t("english") }}</option>
109
+ </select>
110
+ <button @click="refreshAll" class="btn">{{ t("refresh") }}</button>
111
+ <button @click="activeView = 'import'" class="btn-primary">{{ t("importProject") }}</button>
112
+ </div>
113
+ </header>
114
+
115
+ <section v-if="activeView === 'dashboard'" class="space-y-5">
116
+ <div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
117
+ <div v-for="s in summaryCards" :key="s.label" class="panel rounded-xl border p-4">
118
+ <div class="text-sm muted">{{ s.label }}</div>
119
+ <div class="mt-2 text-2xl font-semibold">{{ s.value }}</div>
120
+ <div class="mt-1 text-xs muted">{{ s.note }}</div>
121
+ </div>
122
+ </div>
123
+
124
+ <div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_390px]">
125
+ <section class="panel rounded-xl border p-5">
126
+ <div v-if="!selectedProject" class="py-20 text-center muted">{{ t("selectProject") }}</div>
127
+ <div v-else>
128
+ <div class="flex flex-col gap-3 border-b pb-5 line xl:flex-row xl:items-start xl:justify-between">
129
+ <div>
130
+ <div class="flex items-center gap-2">
131
+ <span :class="['dot', projectStatusClass(selectedSlug)]"></span>
132
+ <h2 class="text-xl font-semibold">{{ selectedProject.displayName }}</h2>
133
+ <span v-if="selectedProject.isReference" class="chip">{{ t("reference") }}</span>
134
+ </div>
135
+ <div class="mt-2 break-all text-xs muted">{{ selectedProject.localPath }}</div>
136
+ </div>
137
+ <div class="flex flex-wrap gap-2">
138
+ <button @click="validateGit(selectedSlug)" class="btn">{{ t("validateGit") }}</button>
139
+ <button @click="scanProject(selectedSlug)" class="btn">{{ t("scan") }}</button>
140
+ <button @click="runJob('safe', selectedSlug)" class="btn-primary">{{ t("safeAnalyze") }}</button>
141
+ </div>
142
+ </div>
143
+
144
+ <div class="mt-5 grid gap-4 xl:grid-cols-4">
145
+ <status-card :label="t('repo')" :value="selectedProject.repoStatus || 'unknown'" :status="repoStatusLevel(selectedProject.repoStatus)"></status-card>
146
+ <status-card :label="t('pending')" :value="String(selectedProject.lastScanPendingCount || 0)" :status="(selectedProject.lastScanPendingCount || 0) > 0 ? 'warn' : 'good'"></status-card>
147
+ <status-card :label="t('goal')" :value="selectedProject.goalStatus || 'not-created'" :status="selectedProject.goalStatus === 'accepted' ? 'good' : 'warn'"></status-card>
148
+ <status-card :label="t('kb')" :value="isKbInit(selectedSlug) ? 'ready' : 'missing'" :status="isKbInit(selectedSlug) ? 'good' : 'bad'"></status-card>
149
+ </div>
150
+
151
+ <div class="mt-5 grid gap-4 xl:grid-cols-2">
152
+ <div class="panel2 rounded-xl border p-4">
153
+ <div class="mb-3 flex items-center justify-between">
154
+ <h3 class="font-medium">{{ t("projectOperations") }}</h3>
155
+ <span class="chip">{{ selectedProject.aiProfileId || "mock-agent" }}</span>
156
+ </div>
157
+ <div class="grid gap-2 md:grid-cols-2">
158
+ <button @click="initProject(selectedSlug)" class="btn">{{ t("initKb") }}</button>
159
+ <button @click="migrateV2(selectedSlug)" class="btn">{{ t("migrateV2") }}</button>
160
+ <button @click="runJob('scan', selectedSlug)" class="btn">{{ t("runScanJob") }}</button>
161
+ <button @click="runJob('analyze-initial', selectedSlug)" class="btn">{{ t("initialAnalysis") }}</button>
162
+ <button @click="runJob('analyze-commits', selectedSlug)" class="btn">{{ t("commitAnalysis") }}</button>
163
+ <button @click="validateKb(selectedSlug)" class="btn">{{ t("validateKb") }}</button>
164
+ <a v-if="isKbInit(selectedSlug)" :href="kbUrl(selectedSlug)" target="_blank" class="btn text-center">{{ t("openKb") }}</a>
165
+ <a :href="goalUrl(selectedSlug)" target="_blank" class="btn text-center">{{ t("openGoal") }}</a>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="panel2 rounded-xl border p-4">
170
+ <div class="mb-3 flex items-center justify-between">
171
+ <h3 class="font-medium">{{ t("issues") }}</h3>
172
+ <span class="chip">{{ projectIssues(selectedSlug).length }}</span>
173
+ </div>
174
+ <div v-if="projectIssues(selectedSlug).length" class="space-y-2">
175
+ <div v-for="issue in projectIssues(selectedSlug)" :key="issue" class="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm" style="color: var(--warn)">
176
+ {{ issue }}
177
+ </div>
178
+ </div>
179
+ <div v-else class="rounded-lg border p-3 text-sm panel">{{ t("noIssue") }}</div>
180
+ </div>
181
+ </div>
182
+
183
+ <div class="mt-5 grid gap-4 xl:grid-cols-2">
184
+ <div class="panel2 rounded-xl border p-4">
185
+ <h3 class="font-medium">{{ t("gitSnapshot") }}</h3>
186
+ <dl class="mt-3 grid gap-2 text-sm">
187
+ <div class="flex justify-between gap-3"><dt class="muted">{{ t("branch") }}</dt><dd>{{ selectedProject.currentBranch || "-" }}</dd></div>
188
+ <div class="flex justify-between gap-3"><dt class="muted">{{ t("defaultBranch") }}</dt><dd>{{ selectedProject.defaultBranch || "-" }}</dd></div>
189
+ <div class="flex justify-between gap-3"><dt class="muted">HEAD</dt><dd class="truncate font-mono text-xs">{{ selectedProject.headCommit || "-" }}</dd></div>
190
+ <div class="flex justify-between gap-3"><dt class="muted">{{ t("lastAnalyzed") }}</dt><dd class="truncate font-mono text-xs">{{ selectedProject.lastAnalyzedCommit || "-" }}</dd></div>
191
+ <div class="flex justify-between gap-3"><dt class="muted">{{ t("remote") }}</dt><dd class="truncate text-xs">{{ selectedProject.remoteUrl || "-" }}</dd></div>
192
+ </dl>
193
+ </div>
194
+
195
+ <div class="panel2 rounded-xl border p-4">
196
+ <div class="mb-3 flex items-center justify-between">
197
+ <h3 class="font-medium">{{ t("hook") }}</h3>
198
+ <span class="chip">{{ hookStatus.installed ? t("installed") : t("notInstalled") }}</span>
199
+ </div>
200
+ <div class="grid gap-2 md:grid-cols-3">
201
+ <button @click="loadHookStatus(selectedSlug)" class="btn">{{ t("checkHook") }}</button>
202
+ <button @click="installHook(selectedSlug)" class="btn">{{ t("install") }}</button>
203
+ <button @click="uninstallHook(selectedSlug)" class="btn-danger">{{ t("uninstall") }}</button>
204
+ </div>
205
+ <div class="mt-3 text-xs muted">{{ hookStatus.message || hookStatus.path || t("hookNotLoaded") }}</div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </section>
210
+
211
+ <aside class="space-y-5">
212
+ <section class="panel rounded-xl border p-5">
213
+ <div class="mb-4 flex items-center justify-between">
214
+ <h2 class="font-semibold">{{ t("runningJobs") }}</h2>
215
+ <span class="chip">{{ runningJobs.length }}</span>
216
+ </div>
217
+ <div v-if="runningJobs.length" class="space-y-3">
218
+ <div v-for="job in runningJobs" :key="job.jobId" class="panel2 rounded-xl border p-3">
219
+ <div class="flex items-center justify-between gap-2">
220
+ <div class="truncate text-sm font-medium">{{ job.mode || "job" }} / {{ job.slug }}</div>
221
+ <span class="dot warn"></span>
222
+ </div>
223
+ <div class="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-500/20">
224
+ <div class="h-full w-2/3 rounded-full" style="background: var(--warn)"></div>
225
+ </div>
226
+ <div class="mt-2 text-xs muted">{{ job.status }} / {{ formatTime(job.startTime) }}</div>
227
+ </div>
228
+ </div>
229
+ <div v-else class="rounded-lg border px-3 py-6 text-center text-sm panel2 muted">{{ t("noRunningJob") }}</div>
230
+ </section>
231
+
232
+ <section class="panel rounded-xl border p-5">
233
+ <div class="mb-3 flex items-center justify-between">
234
+ <h2 class="font-semibold">{{ t("lastResult") }}</h2>
235
+ <button @click="activeView = 'logs'" class="btn">{{ t("logs") }}</button>
236
+ </div>
237
+ <div class="text-sm">{{ lastRun.mode || "none" }} / {{ lastRun.slug || "none" }}</div>
238
+ <div class="mt-1 text-xs muted">{{ lastRun.time ? formatTime(lastRun.time) : "never" }}</div>
239
+ <div v-if="lastRun.status" class="mt-3 inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs panel2">
240
+ <span :class="['dot', lastRun.status === 'success' ? 'good' : lastRun.status === 'partial' ? 'warn' : 'bad']"></span>
241
+ {{ lastRun.status }}
242
+ </div>
243
+ </section>
244
+ </aside>
245
+ </div>
246
+ </section>
247
+
248
+ <section v-else-if="activeView === 'import'" class="grid gap-5 xl:grid-cols-[minmax(0,760px)_1fr]">
249
+ <form @submit.prevent="addProject" class="panel rounded-xl border p-5">
250
+ <h2 class="text-lg font-semibold">{{ t("importProject") }}</h2>
251
+ <p class="mt-1 text-sm muted">{{ t("importHelp") }}</p>
252
+ <div class="mt-5 grid gap-4">
253
+ <label class="grid gap-1 text-sm">{{ t("slug") }}
254
+ <input v-model="form.slug" required pattern="[a-z0-9][a-z0-9\-]*" class="input font-mono" placeholder="my-project" />
255
+ </label>
256
+ <label class="grid gap-1 text-sm">{{ t("displayName") }}
257
+ <input v-model="form.displayName" required class="input" placeholder="My Project" />
258
+ </label>
259
+ <label class="grid gap-1 text-sm">{{ t("localPath") }}
260
+ <input v-model="form.localPath" required class="input font-mono text-xs" placeholder="D:\SanQian.Xu\my-project" />
261
+ </label>
262
+ <label class="grid gap-1 text-sm">{{ t("gitPath") }}
263
+ <input v-model="form.gitPath" class="input font-mono text-xs" placeholder="Leave blank if same as local path" />
264
+ </label>
265
+ <div class="grid gap-4 md:grid-cols-2">
266
+ <label class="grid gap-1 text-sm">{{ t("primaryLanguage") }}
267
+ <input v-model="form.primaryLanguage" class="input" placeholder="TypeScript" />
268
+ </label>
269
+ <label class="grid gap-1 text-sm">{{ t("tags") }}
270
+ <input v-model="form.tagsStr" class="input" placeholder="react, vite, api" />
271
+ </label>
272
+ </div>
273
+ <label class="grid gap-1 text-sm">{{ t("knowledgeLanguage") }}
274
+ <select v-model="form.knowledgeLanguage" class="input">
275
+ <option value="zh-CN">{{ t("chinese") }}</option>
276
+ <option value="en-US">{{ t("english") }}</option>
277
+ </select>
278
+ </label>
279
+ <div class="flex flex-wrap gap-4 text-sm">
280
+ <label class="flex items-center gap-2"><input type="checkbox" v-model="form.isReference" /> {{ t("referenceProject") }}</label>
281
+ <label class="flex items-center gap-2"><input type="checkbox" v-model="form.initNow" /> {{ t("initKbDirs") }}</label>
282
+ </div>
283
+ <div v-if="formError" class="rounded-lg border border-red-500/25 bg-red-500/10 p-3 text-sm" style="color: var(--bad)">{{ formError }}</div>
284
+ <div v-if="formOk" class="rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-3 text-sm" style="color: var(--good)">{{ formOk }}</div>
285
+ <div class="flex gap-2">
286
+ <button type="submit" :disabled="submitting" class="btn-primary disabled:opacity-50">{{ submitting ? t("saving") : t("import") }}</button>
287
+ <button type="button" @click="resetForm" class="btn">{{ t("reset") }}</button>
288
+ </div>
289
+ </div>
290
+ </form>
291
+ <aside class="panel rounded-xl border p-5">
292
+ <h2 class="font-semibold">{{ t("importTarget") }}</h2>
293
+ <div class="mt-4 space-y-3 text-sm muted">
294
+ <p>{{ t("kbPath") }}: <code>{{ displayProjectKbPath(form.slug || "<slug>") }}</code></p>
295
+ <p>{{ t("schemaNormalized") }}</p>
296
+ <p>{{ t("gitPopulate") }}</p>
297
+ </div>
298
+ </aside>
299
+ </section>
300
+
301
+ <section v-else-if="activeView === 'ai'" class="grid gap-5 xl:grid-cols-[minmax(0,760px)_1fr]">
302
+ <div class="panel rounded-xl border p-5">
303
+ <div class="flex items-center justify-between gap-3">
304
+ <div>
305
+ <h2 class="text-lg font-semibold">{{ t("aiProfiles") }}</h2>
306
+ <p class="mt-1 text-sm muted">{{ t("backedByAi") }}</p>
307
+ </div>
308
+ <button @click="saveAiProfiles" class="btn-primary">{{ t("save") }}</button>
309
+ </div>
310
+ <div class="mt-5 grid gap-4">
311
+ <label class="grid gap-1 text-sm">{{ t("defaultProfile") }}
312
+ <select v-model="aiConfig.defaultProfileId" class="input">
313
+ <option v-for="p in aiConfig.profiles" :key="p.id" :value="p.id">{{ p.id }} - {{ p.name || p.id }}</option>
314
+ </select>
315
+ </label>
316
+ <label class="grid gap-1 text-sm">{{ t("rawProfiles") }}
317
+ <textarea v-model="aiProfilesText" rows="16" class="input font-mono text-xs"></textarea>
318
+ </label>
319
+ <div v-if="aiMessage" class="rounded-lg border p-3 text-sm panel2">{{ aiMessage }}</div>
320
+ </div>
321
+ </div>
322
+ <aside class="panel rounded-xl border p-5">
323
+ <h2 class="font-semibold">{{ t("projectProfile") }}</h2>
324
+ <div v-if="selectedProject" class="mt-4 grid gap-3">
325
+ <div class="text-sm muted">{{ selectedProject.displayName }}</div>
326
+ <label class="grid gap-1 text-sm">{{ t("defaultProfile") }}
327
+ <select v-model="selectedAiProfileId" class="input">
328
+ <option v-for="p in aiConfig.profiles" :key="p.id" :value="p.id">{{ p.id }}</option>
329
+ </select>
330
+ </label>
331
+ <label class="grid gap-1 text-sm">{{ t("knowledgeLanguage") }}
332
+ <select v-model="selectedKnowledgeLanguage" class="input">
333
+ <option value="zh-CN">{{ t("chinese") }}</option>
334
+ <option value="en-US">{{ t("english") }}</option>
335
+ </select>
336
+ </label>
337
+ <button @click="saveProjectSettings(selectedSlug)" class="btn-primary">{{ t("assignProject") }}</button>
338
+ <div class="text-xs muted">{{ t("availableAdapters") }}: {{ adapters.map(a => a.id).join(", ") || "-" }}</div>
339
+ </div>
340
+ </aside>
341
+ </section>
342
+
343
+ <section v-else-if="activeView === 'runs'" class="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
344
+ <aside class="panel rounded-xl border p-5">
345
+ <div class="mb-3 flex items-center justify-between">
346
+ <h2 class="font-semibold">{{ t("runsAndDrafts") }}</h2>
347
+ <button @click="loadRuns(selectedSlug)" class="btn">{{ t("reload") }}</button>
348
+ </div>
349
+ <div class="space-y-2">
350
+ <button v-for="run in runs" :key="run.runId" @click="selectRun(run.runId)"
351
+ :class="['w-full rounded-lg border p-3 text-left text-sm', selectedRunId === run.runId ? 'panel2 border-slate-400' : 'panel border']">
352
+ <div class="font-medium">{{ run.type }} / {{ run.status }}</div>
353
+ <div class="mt-1 truncate text-xs muted">{{ run.runId }}</div>
354
+ <div class="mt-1 text-xs muted">{{ formatTime(run.startedAt) }}</div>
355
+ </button>
356
+ </div>
357
+ </aside>
358
+ <section class="panel rounded-xl border p-5">
359
+ <div v-if="!selectedRun" class="py-16 text-center muted">{{ t("selectRun") }}</div>
360
+ <div v-else>
361
+ <div class="flex flex-col gap-3 border-b pb-4 line xl:flex-row xl:items-start xl:justify-between">
362
+ <div>
363
+ <h2 class="text-lg font-semibold">{{ selectedRun.type }} / {{ selectedRun.status }}</h2>
364
+ <div class="mt-1 text-xs muted">{{ selectedRun.runId }}</div>
365
+ </div>
366
+ <div class="flex flex-wrap gap-2">
367
+ <button @click="loadRunDetail(selectedSlug, selectedRun.runId)" class="btn">{{ t("refreshRun") }}</button>
368
+ <button @click="applySelectedDrafts" class="btn-primary">{{ t("applySelected") }}</button>
369
+ <button @click="rejectRun" class="btn-danger">{{ t("rejectRun") }}</button>
370
+ </div>
371
+ </div>
372
+
373
+ <div class="mt-4 flex items-center gap-3 text-sm">
374
+ <label class="flex items-center gap-2"><input type="checkbox" v-model="allowGoalEdit" /> {{ t("allowGoalEdit") }}</label>
375
+ <span class="muted">{{ drafts.length }} {{ t("draftFiles") }}</span>
376
+ </div>
377
+
378
+ <div class="mt-4 grid gap-4 xl:grid-cols-[320px_1fr]">
379
+ <div class="space-y-2">
380
+ <label v-for="d in drafts" :key="d.path" class="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3 text-sm panel2">
381
+ <span class="truncate">{{ d.path }}</span>
382
+ <input type="checkbox" v-model="draftSelection[d.path]" />
383
+ </label>
384
+ </div>
385
+ <div>
386
+ <select v-model="previewDraftPath" @change="loadDraftPreview" class="input mb-3 w-full">
387
+ <option value="">{{ t("selectDraftPreview") }}</option>
388
+ <option v-for="d in drafts" :key="d.path" :value="d.path">{{ d.path }}</option>
389
+ </select>
390
+ <pre class="max-h-[62vh] overflow-auto rounded-xl bg-slate-950 p-4 text-xs text-slate-100 scrollbar-thin whitespace-pre-wrap">{{ draftPreview || t("noPreview") }}</pre>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ </section>
395
+ </section>
396
+
397
+ <section v-else-if="activeView === 'schedule'" class="grid gap-5 xl:grid-cols-2">
398
+ <div class="panel rounded-xl border p-5">
399
+ <h2 class="text-lg font-semibold">{{ t("schedule") }}</h2>
400
+ <div v-if="!schedule" class="mt-4 muted">{{ t("loading") }}</div>
401
+ <div v-else-if="!schedule.registered" class="mt-4 rounded-lg border p-3 panel2 muted">
402
+ {{ t("noSchedule") }} {{ schedule.error || "" }}
403
+ </div>
404
+ <div v-else class="mt-4 grid gap-3 text-sm">
405
+ <div class="flex justify-between gap-3"><span class="muted">{{ t("task") }}</span><code>KB-GitCommits-Daily</code></div>
406
+ <div class="flex justify-between gap-3"><span class="muted">{{ t("state") }}</span><span>{{ schedule.status || "-" }}</span></div>
407
+ <div class="flex justify-between gap-3"><span class="muted">{{ t("nextRun") }}</span><span>{{ schedule.nextRun ? new Date(schedule.nextRun).toLocaleString() : "-" }}</span></div>
408
+ <div class="flex justify-between gap-3"><span class="muted">{{ t("lastResult") }}</span><span>{{ schedule.lastResult || "-" }}</span></div>
409
+ </div>
410
+ </div>
411
+ <div class="panel rounded-xl border p-5">
412
+ <h2 class="text-lg font-semibold">{{ t("controls") }}</h2>
413
+ <div class="mt-4 grid gap-4">
414
+ <label class="grid gap-1 text-sm">{{ t("frequency") }}
415
+ <select v-model="freq.frequency" class="input">
416
+ <option value="off">Off</option>
417
+ <option value="hourly">Every hour</option>
418
+ <option value="every6h">Every 6 hours</option>
419
+ <option value="every12h">Every 12 hours</option>
420
+ <option value="daily">Daily</option>
421
+ <option value="weekly">Weekly</option>
422
+ </select>
423
+ </label>
424
+ <label v-if="['daily', 'weekly'].includes(freq.frequency)" class="grid gap-1 text-sm">{{ t("time") }}
425
+ <input v-model="freq.time" type="time" class="input" />
426
+ </label>
427
+ <label class="grid gap-1 text-sm">{{ t("runner") }}
428
+ <select v-model="freq.runner" class="input">
429
+ <option value="safe">safe runner</option>
430
+ <option value="legacy">legacy PowerShell</option>
431
+ </select>
432
+ </label>
433
+ <div v-if="scheduleMsg" class="rounded-lg border p-3 text-sm panel2">{{ scheduleMsg }}</div>
434
+ <div class="flex gap-2">
435
+ <button @click="applySchedule" class="btn-primary">{{ t("apply") }}</button>
436
+ <button @click="deleteSchedule" class="btn-danger">{{ t("stop") }}</button>
437
+ </div>
438
+ </div>
439
+ </div>
440
+ </section>
441
+
442
+ <section v-else-if="activeView === 'logs'" class="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
443
+ <aside class="panel rounded-xl border p-5">
444
+ <div class="mb-3 flex items-center justify-between">
445
+ <h2 class="font-semibold">{{ t("jobHistory") }}</h2>
446
+ <button @click="loadJobs" class="btn">{{ t("reload") }}</button>
447
+ </div>
448
+ <div class="space-y-2">
449
+ <button v-for="job in jobHistory" :key="job.jobId" @click="loadJobDetail(job.jobId)"
450
+ class="w-full rounded-lg border p-3 text-left text-sm panel2">
451
+ <div class="font-medium">{{ job.mode }} / {{ job.status }}</div>
452
+ <div class="mt-1 truncate text-xs muted">{{ job.slug }} / {{ job.jobId }}</div>
453
+ </button>
454
+ </div>
455
+ </aside>
456
+ <section class="panel rounded-xl border p-5">
457
+ <div class="mb-4 flex items-center justify-between">
458
+ <div>
459
+ <h2 class="text-lg font-semibold">{{ t("logOutput") }}</h2>
460
+ <div class="mt-1 text-sm muted">{{ logTitle }}</div>
461
+ </div>
462
+ <button @click="refreshAll" class="btn">{{ t("refresh") }}</button>
463
+ </div>
464
+ <pre class="max-h-[72vh] overflow-auto rounded-xl bg-slate-950 p-4 text-xs text-slate-100 scrollbar-thin whitespace-pre-wrap">{{ logOutput || t("noOutput") }}</pre>
465
+ </section>
466
+ </section>
467
+ </main>
468
+ </div>
469
+ </div>
470
+ </div>
471
+
472
+ <script>
473
+ const { createApp, reactive, ref, computed, onMounted, onUnmounted, watch } = Vue;
474
+
475
+ const I18N = {
476
+ en: {
477
+ appName: "AI Knowledge Base",
478
+ controlCenter: "Control Center",
479
+ connecting: "connecting...",
480
+ projects: "Projects",
481
+ dashboard: "Dashboard",
482
+ import: "Import",
483
+ aiProfiles: "AI Profiles",
484
+ runsDrafts: "Runs / Drafts",
485
+ schedule: "Schedule",
486
+ logs: "Logs",
487
+ consoleSubtitle: "Backend-driven project supervision console",
488
+ refreshing: "refreshing",
489
+ refresh: "Refresh",
490
+ importProject: "Import Project",
491
+ projectSupervision: "Project Supervision",
492
+ runsAndDrafts: "Runs and Drafts",
493
+ selectProject: "Select a project from the left sidebar.",
494
+ reference: "reference",
495
+ validateGit: "Validate Git",
496
+ scan: "Scan",
497
+ safeAnalyze: "Safe Analyze",
498
+ repo: "Repo",
499
+ pending: "Pending",
500
+ goal: "Goal",
501
+ kb: "KB",
502
+ projectOperations: "Project Operations",
503
+ initKb: "Init KB",
504
+ migrateV2: "Migrate v2",
505
+ runScanJob: "Run scan job",
506
+ initialAnalysis: "Initial analysis",
507
+ commitAnalysis: "Commit analysis",
508
+ validateKb: "Validate KB",
509
+ openKb: "Open KB",
510
+ openGoal: "Open Goal",
511
+ issues: "Issues",
512
+ noIssue: "No blocking issue detected.",
513
+ gitSnapshot: "Git Snapshot",
514
+ branch: "Branch",
515
+ defaultBranch: "Default",
516
+ lastAnalyzed: "Last analyzed",
517
+ remote: "Remote",
518
+ hook: "Hook",
519
+ installed: "installed",
520
+ notInstalled: "not installed",
521
+ checkHook: "Check hook",
522
+ install: "Install",
523
+ uninstall: "Uninstall",
524
+ hookNotLoaded: "Hook status not loaded.",
525
+ runningJobs: "Running Jobs",
526
+ noRunningJob: "No running job.",
527
+ lastResult: "Last Result",
528
+ saving: "Saving",
529
+ save: "Save",
530
+ reset: "Reset",
531
+ slug: "Slug",
532
+ displayName: "Display name",
533
+ localPath: "Local path",
534
+ gitPath: "Git path",
535
+ primaryLanguage: "Primary language",
536
+ tags: "Tags",
537
+ referenceProject: "Reference project",
538
+ initKbDirs: "Init KB directories",
539
+ importHelp: "The backend will persist the project, initialize KB files, and auto-validate Git on upsert.",
540
+ importTarget: "Import target",
541
+ kbPath: "KB path",
542
+ schemaNormalized: "Schema fields are normalized by the backend.",
543
+ gitPopulate: "Git validation will populate repoStatus, branch, remote and HEAD commit.",
544
+ backedByAi: "Backed by /api/ai-profiles and project settings.",
545
+ defaultProfile: "Default profile",
546
+ rawProfiles: "Raw ai-profiles.json",
547
+ projectProfile: "Project profile",
548
+ assignProject: "Save project settings",
549
+ availableAdapters: "Available adapters",
550
+ knowledgeLanguage: "Knowledge output language",
551
+ uiLanguage: "UI language",
552
+ chinese: "Chinese",
553
+ english: "English",
554
+ reload: "Reload",
555
+ selectRun: "Select a run to review drafts.",
556
+ refreshRun: "Refresh run",
557
+ applySelected: "Apply selected",
558
+ rejectRun: "Reject run",
559
+ allowGoalEdit: "allow project-goal.md edit",
560
+ draftFiles: "draft files",
561
+ selectDraftPreview: "Select draft preview",
562
+ noPreview: "(no preview)",
563
+ loading: "Loading...",
564
+ noSchedule: "No scheduled task found.",
565
+ task: "Task",
566
+ state: "State",
567
+ nextRun: "Next run",
568
+ controls: "Controls",
569
+ frequency: "Frequency",
570
+ time: "Time",
571
+ runner: "Runner",
572
+ apply: "Apply",
573
+ stop: "Stop",
574
+ jobHistory: "Job History",
575
+ logOutput: "Log Output",
576
+ noOutput: "(no output yet)",
577
+ darkMode: "Dark mode",
578
+ lightMode: "Light mode",
579
+ registeredProjects: "registered projects",
580
+ activeBackendJobs: "active backend jobs",
581
+ pendingFromLastScan: "from last scan",
582
+ failedOrPartialJobs: "failed or partial jobs",
583
+ recentIssues: "Recent issues",
584
+ pendingCommits: "Pending commits",
585
+ },
586
+ zh: {
587
+ appName: "AI 知识库",
588
+ controlCenter: "控制中心",
589
+ connecting: "连接中...",
590
+ projects: "项目",
591
+ dashboard: "仪表盘",
592
+ import: "导入",
593
+ aiProfiles: "AI 配置",
594
+ runsDrafts: "运行 / 草稿",
595
+ schedule: "定时任务",
596
+ logs: "日志",
597
+ consoleSubtitle: "由后端真实状态驱动的项目监督控制台",
598
+ refreshing: "刷新中",
599
+ refresh: "刷新",
600
+ importProject: "导入项目",
601
+ projectSupervision: "项目监督",
602
+ runsAndDrafts: "运行与草稿",
603
+ selectProject: "请从左侧选择一个项目。",
604
+ reference: "参考项目",
605
+ validateGit: "校验 Git",
606
+ scan: "扫描",
607
+ safeAnalyze: "安全分析",
608
+ repo: "仓库",
609
+ pending: "待分析",
610
+ goal: "目标",
611
+ kb: "知识库",
612
+ projectOperations: "项目操作",
613
+ initKb: "初始化知识库",
614
+ migrateV2: "迁移 v2",
615
+ runScanJob: "运行扫描任务",
616
+ initialAnalysis: "整体分析",
617
+ commitAnalysis: "提交分析",
618
+ validateKb: "校验知识库",
619
+ openKb: "打开知识库",
620
+ openGoal: "打开目标",
621
+ issues: "异常点",
622
+ noIssue: "未检测到阻塞异常。",
623
+ gitSnapshot: "Git 快照",
624
+ branch: "分支",
625
+ defaultBranch: "默认分支",
626
+ lastAnalyzed: "上次分析",
627
+ remote: "远端",
628
+ hook: "Hook",
629
+ installed: "已安装",
630
+ notInstalled: "未安装",
631
+ checkHook: "检查 Hook",
632
+ install: "安装",
633
+ uninstall: "卸载",
634
+ hookNotLoaded: "尚未加载 Hook 状态。",
635
+ runningJobs: "运行中的任务",
636
+ noRunningJob: "当前没有运行中的任务。",
637
+ lastResult: "最近结果",
638
+ saving: "保存中",
639
+ save: "保存",
640
+ reset: "重置",
641
+ slug: "标识",
642
+ displayName: "显示名称",
643
+ localPath: "本地路径",
644
+ gitPath: "Git 路径",
645
+ primaryLanguage: "主要语言",
646
+ tags: "标签",
647
+ referenceProject: "参考项目",
648
+ initKbDirs: "初始化知识库目录",
649
+ importHelp: "后端会保存项目、初始化知识库文件,并在写入时自动校验 Git。",
650
+ importTarget: "导入目标",
651
+ kbPath: "知识库路径",
652
+ schemaNormalized: "后端会自动归一化项目字段。",
653
+ gitPopulate: "Git 校验会写入 repoStatus、分支、远端和 HEAD commit。",
654
+ backedByAi: "由 /api/ai-profiles 和项目级设置驱动。",
655
+ defaultProfile: "默认模型配置",
656
+ rawProfiles: "原始 ai-profiles.json",
657
+ projectProfile: "项目模型配置",
658
+ assignProject: "保存项目设置",
659
+ availableAdapters: "可用适配器",
660
+ knowledgeLanguage: "知识库输出语言",
661
+ uiLanguage: "界面语言",
662
+ chinese: "中文",
663
+ english: "英文",
664
+ reload: "重新加载",
665
+ selectRun: "选择一次运行来审核草稿。",
666
+ refreshRun: "刷新运行",
667
+ applySelected: "应用选中草稿",
668
+ rejectRun: "拒绝运行",
669
+ allowGoalEdit: "允许修改 project-goal.md",
670
+ draftFiles: "个草稿文件",
671
+ selectDraftPreview: "选择草稿预览",
672
+ noPreview: "(暂无预览)",
673
+ loading: "加载中...",
674
+ noSchedule: "未找到定时任务。",
675
+ task: "任务",
676
+ state: "状态",
677
+ nextRun: "下次运行",
678
+ controls: "控制",
679
+ frequency: "频率",
680
+ time: "时间",
681
+ runner: "运行器",
682
+ apply: "应用",
683
+ stop: "停止",
684
+ jobHistory: "任务历史",
685
+ logOutput: "日志输出",
686
+ noOutput: "(暂无输出)",
687
+ darkMode: "深色模式",
688
+ lightMode: "浅色模式",
689
+ registeredProjects: "已登记项目",
690
+ activeBackendJobs: "活跃后端任务",
691
+ pendingFromLastScan: "来自上次扫描",
692
+ failedOrPartialJobs: "失败或部分成功任务",
693
+ recentIssues: "近期异常",
694
+ pendingCommits: "待分析提交",
695
+ },
696
+ };
697
+
698
+ const StatusCard = {
699
+ props: ["label", "value", "status"],
700
+ template: `
701
+ <div class="panel2 rounded-xl border p-4">
702
+ <div class="text-sm muted">{{ label }}</div>
703
+ <div class="mt-3 flex items-center gap-2">
704
+ <span :class="['dot', status || 'idle']"></span>
705
+ <span class="text-lg font-semibold">{{ value }}</span>
706
+ </div>
707
+ </div>
708
+ `
709
+ };
710
+
711
+ createApp({
712
+ components: { StatusCard },
713
+ setup() {
714
+ const kbRoot = ref("");
715
+ const projects = ref(null);
716
+ const schedule = ref(null);
717
+ const lastRun = ref({ time: null, status: null, slug: null, mode: null, output: "" });
718
+ const runningJobs = ref([]);
719
+ const jobHistory = ref([]);
720
+ const selectedJob = ref(null);
721
+ const loading = ref(false);
722
+ const pollError = ref("");
723
+ const activeView = ref("dashboard");
724
+ const selectedSlug = ref("");
725
+ const theme = ref(localStorage.getItem("kb-theme") || "light");
726
+ const uiLanguage = ref(localStorage.getItem("kb-ui-language") || "zh");
727
+
728
+ const runs = ref([]);
729
+ const selectedRunId = ref("");
730
+ const selectedRun = ref(null);
731
+ const drafts = ref([]);
732
+ const draftSelection = reactive({});
733
+ const previewDraftPath = ref("");
734
+ const draftPreview = ref("");
735
+ const allowGoalEdit = ref(false);
736
+
737
+ const aiConfig = reactive({ schema: "ai-profiles/v1", defaultProfileId: "mock-agent", profiles: [] });
738
+ const aiProfilesText = ref("");
739
+ const adapters = ref([]);
740
+ const aiMessage = ref("");
741
+ const selectedAiProfileId = ref("mock-agent");
742
+ const selectedKnowledgeLanguage = ref("zh-CN");
743
+
744
+ const hookStatus = reactive({ installed: false, path: "", message: "" });
745
+ const actionMessage = ref("");
746
+
747
+ const form = reactive({ slug: "", displayName: "", localPath: "", gitPath: "", primaryLanguage: "", tagsStr: "", isReference: false, initNow: true, knowledgeLanguage: "zh-CN" });
748
+ const submitting = ref(false);
749
+ const formError = ref("");
750
+ const formOk = ref("");
751
+ const busySlug = ref("");
752
+
753
+ const freq = reactive({ frequency: "daily", time: "08:00", runner: "safe" });
754
+ const scheduleMsg = ref("");
755
+
756
+ const projectList = computed(() => Object.entries(projects.value || {}).map(([slug, cfg]) => ({ slug, cfg })));
757
+ const selectedProject = computed(() => selectedSlug.value && projects.value ? projects.value[selectedSlug.value] : null);
758
+ function t(key) {
759
+ return (I18N[uiLanguage.value] && I18N[uiLanguage.value][key]) || I18N.en[key] || key;
760
+ }
761
+
762
+ const pageTitle = computed(() => ({
763
+ dashboard: t("projectSupervision"),
764
+ import: t("importProject"),
765
+ ai: t("aiProfiles"),
766
+ runs: t("runsAndDrafts"),
767
+ schedule: t("schedule"),
768
+ logs: t("logs")
769
+ }[activeView.value] || t("projectSupervision")));
770
+
771
+ const summaryCards = computed(() => {
772
+ const list = projectList.value;
773
+ const pending = list.reduce((sum, p) => sum + Number(p.cfg.lastScanPendingCount || 0), 0);
774
+ const failed = jobHistory.value.filter(j => j.status === "failed" || j.status === "partial").length;
775
+ return [
776
+ { label: t("projects"), value: list.length, note: t("registeredProjects") },
777
+ { label: t("runningJobs"), value: runningJobs.value.length, note: t("activeBackendJobs") },
778
+ { label: t("pendingCommits"), value: pending, note: t("pendingFromLastScan") },
779
+ { label: t("recentIssues"), value: failed, note: t("failedOrPartialJobs") }
780
+ ];
781
+ });
782
+
783
+ const navItems = computed(() => [
784
+ { key: "dashboard", label: t("dashboard"), badge: runningJobs.value.length || "" },
785
+ { key: "import", label: t("import") },
786
+ { key: "ai", label: t("aiProfiles") },
787
+ { key: "runs", label: t("runsDrafts"), badge: runs.value.length || "" },
788
+ { key: "schedule", label: t("schedule") },
789
+ { key: "logs", label: t("logs"), badge: lastRun.value.status === "failed" ? "failed" : "" }
790
+ ]);
791
+
792
+ const logOutput = computed(() => selectedJob.value ? (selectedJob.value.output || "") : (lastRun.value.output || ""));
793
+ const logTitle = computed(() => selectedJob.value ? `${selectedJob.value.mode} / ${selectedJob.value.slug} / ${selectedJob.value.status}` : `${lastRun.value.mode || "last"} / ${lastRun.value.slug || "none"} / ${lastRun.value.status || "none"}`);
794
+
795
+ watch(selectedProject, p => {
796
+ if (p) {
797
+ selectedAiProfileId.value = p.aiProfileId || aiConfig.defaultProfileId || "mock-agent";
798
+ selectedKnowledgeLanguage.value = p.knowledgeLanguage || "zh-CN";
799
+ }
800
+ });
801
+
802
+ watch(uiLanguage, value => {
803
+ localStorage.setItem("kb-ui-language", value);
804
+ document.documentElement.lang = value === "zh" ? "zh-CN" : "en";
805
+ });
806
+
807
+ function trimTrailingSlash(value) {
808
+ return String(value || "").replace(/[\\\/]+$/, "");
809
+ }
810
+
811
+ function projectKbPath(slug) {
812
+ const root = trimTrailingSlash(kbRoot.value || "D:\\SanQian.Xu\\project-knowledge-base");
813
+ return `${root}\\projects\\${slug}`;
814
+ }
815
+
816
+ function displayProjectKbPath(slug) {
817
+ return `${trimTrailingSlash(kbRoot.value || "project-knowledge-base")}\\projects\\${slug}`;
818
+ }
819
+
820
+ function fileUrlFromPath(value) {
821
+ const normalized = String(value || "").replace(/\\/g, "/");
822
+ return encodeURI("file:///" + normalized.replace(/^\/+/, ""));
823
+ }
824
+
825
+ function formatTime(value) {
826
+ if (!value) return "-";
827
+ try { return new Date(value).toLocaleString(); } catch { return value; }
828
+ }
829
+
830
+ function selectProject(slug) {
831
+ selectedSlug.value = slug;
832
+ selectedRunId.value = "";
833
+ selectedRun.value = null;
834
+ drafts.value = [];
835
+ previewDraftPath.value = "";
836
+ draftPreview.value = "";
837
+ loadRuns(slug);
838
+ loadHookStatus(slug);
839
+ }
840
+
841
+ function isKbInit(slug) {
842
+ return !!(projects.value && projects.value[slug] && projects.value[slug].kbInitialized);
843
+ }
844
+
845
+ function isProjectRunning(slug) {
846
+ return runningJobs.value.some(j => j.slug === slug || j.slug === "ALL");
847
+ }
848
+
849
+ function repoStatusLevel(status) {
850
+ if (status === "ok" || status === "empty") return status === "ok" ? "good" : "warn";
851
+ if (!status || status === "unknown") return "idle";
852
+ return "bad";
853
+ }
854
+
855
+ function projectStatusClass(slug) {
856
+ const cfg = projects.value && projects.value[slug];
857
+ if (!cfg) return "idle";
858
+ if (isProjectRunning(slug)) return "warn";
859
+ if (!isKbInit(slug)) return "bad";
860
+ if (repoStatusLevel(cfg.repoStatus) === "bad") return "bad";
861
+ if ((cfg.lastScanPendingCount || 0) > 0 || cfg.goalStatus !== "accepted") return "warn";
862
+ return "good";
863
+ }
864
+
865
+ function projectIssues(slug) {
866
+ const cfg = projects.value && projects.value[slug];
867
+ if (!cfg) return [];
868
+ const out = [];
869
+ if (!isKbInit(slug)) out.push("KB directory is not initialized.");
870
+ if (repoStatusLevel(cfg.repoStatus) === "bad") out.push(`Git status is ${cfg.repoStatus}: ${cfg.lastScanError || ""}`);
871
+ if (cfg.repoStatus === "empty") out.push("Repository has no commits.");
872
+ if (!cfg.lastAnalyzedCommit) out.push("lastAnalyzedCommit is empty. Apply drafts after analysis to advance it.");
873
+ if (cfg.goalStatus !== "accepted") out.push("Project goal is not accepted yet.");
874
+ if ((cfg.lastScanPendingCount || 0) > 0) out.push(`${cfg.lastScanPendingCount} pending commits need analysis.`);
875
+ return out;
876
+ }
877
+
878
+ function kbUrl(slug) {
879
+ const cfg = projects.value && projects.value[slug];
880
+ return fileUrlFromPath(`${(cfg && cfg.kbPath) || projectKbPath(slug)}\\README.md`);
881
+ }
882
+
883
+ function goalUrl(slug) {
884
+ const cfg = projects.value && projects.value[slug];
885
+ return fileUrlFromPath(`${(cfg && cfg.kbPath) || projectKbPath(slug)}\\project-goal.md`);
886
+ }
887
+
888
+ async function api(method, url, body) {
889
+ const res = await fetch(url, {
890
+ method,
891
+ headers: body ? { "Content-Type": "application/json" } : undefined,
892
+ body: body ? JSON.stringify(body) : undefined
893
+ });
894
+ const text = await res.text();
895
+ let data = {};
896
+ if (text) {
897
+ try { data = JSON.parse(text); } catch { data = { raw: text }; }
898
+ }
899
+ if (!res.ok) {
900
+ const msg = data.error || data.errors && data.errors.join("; ") || text || `HTTP ${res.status}`;
901
+ throw new Error(msg);
902
+ }
903
+ return data;
904
+ }
905
+
906
+ async function refreshAll() {
907
+ loading.value = true;
908
+ pollError.value = "";
909
+ try {
910
+ const state = await api("GET", "/api/state");
911
+ kbRoot.value = state.kbRoot;
912
+ projects.value = state.projects;
913
+ schedule.value = state.schedule;
914
+ lastRun.value = state.lastRun || lastRun.value;
915
+ if (!selectedSlug.value && projectList.value.length) selectProject(projectList.value[0].slug);
916
+ await Promise.all([loadJobs(), loadAiProfiles(false)]);
917
+ } catch (e) {
918
+ pollError.value = e.message;
919
+ } finally {
920
+ loading.value = false;
921
+ }
922
+ }
923
+
924
+ async function loadJobs() {
925
+ const data = await api("GET", "/api/jobs");
926
+ runningJobs.value = data.running || [];
927
+ jobHistory.value = data.history || [];
928
+ if (data.lastRun) lastRun.value = data.lastRun;
929
+ return data;
930
+ }
931
+
932
+ async function loadJobDetail(jobId) {
933
+ const data = await api("GET", `/api/jobs/${jobId}`);
934
+ selectedJob.value = data.job;
935
+ activeView.value = "logs";
936
+ }
937
+
938
+ async function loadAiProfiles(updateText = true) {
939
+ const data = await api("GET", "/api/ai-profiles");
940
+ Object.assign(aiConfig, data.config || {});
941
+ adapters.value = data.adapters || [];
942
+ if (updateText || !aiProfilesText.value.trim()) aiProfilesText.value = JSON.stringify(aiConfig, null, 2);
943
+ return data;
944
+ }
945
+
946
+ async function saveAiProfiles() {
947
+ try {
948
+ const parsed = JSON.parse(aiProfilesText.value);
949
+ await api("PUT", "/api/ai-profiles", parsed);
950
+ aiMessage.value = "AI profiles saved.";
951
+ await loadAiProfiles();
952
+ } catch (e) {
953
+ aiMessage.value = `Save failed: ${e.message}`;
954
+ }
955
+ }
956
+
957
+ async function setProjectAiProfile(slug, aiProfileId) {
958
+ await api("PUT", `/api/projects/${slug}/ai-profile`, { aiProfileId });
959
+ await refreshAll();
960
+ }
961
+
962
+ async function saveProjectSettings(slug) {
963
+ if (!slug) return;
964
+ await api("PUT", `/api/projects/${slug}/settings`, {
965
+ aiProfileId: selectedAiProfileId.value,
966
+ knowledgeLanguage: selectedKnowledgeLanguage.value
967
+ });
968
+ aiMessage.value = t("assignProject") + " OK.";
969
+ await refreshAll();
970
+ }
971
+
972
+ async function validateGit(slug) {
973
+ await api("POST", `/api/projects/${slug}/validate-git`);
974
+ await refreshAll();
975
+ }
976
+
977
+ async function scanProject(slug) {
978
+ await api("POST", `/api/projects/${slug}/scan`, { maxCommits: 200 });
979
+ await refreshAll();
980
+ }
981
+
982
+ async function validateKb(slug) {
983
+ const data = await api("POST", `/api/projects/${slug}/validate-kb`);
984
+ actionMessage.value = data.ok ? "KB contract valid." : "KB contract invalid.";
985
+ alert(actionMessage.value);
986
+ }
987
+
988
+ async function migrateV2(slug) {
989
+ await api("POST", `/api/projects/${slug}/migrate-v2`);
990
+ await refreshAll();
991
+ }
992
+
993
+ async function initProject(slug) {
994
+ busySlug.value = slug;
995
+ try {
996
+ await api("POST", `/api/projects/${slug}/init`);
997
+ await refreshAll();
998
+ } finally {
999
+ busySlug.value = "";
1000
+ }
1001
+ }
1002
+
1003
+ async function runJob(mode, slug) {
1004
+ const data = await api("POST", "/api/jobs/run", { mode, slug: slug || "ALL" });
1005
+ activeView.value = "logs";
1006
+ await refreshAll();
1007
+ if (data.jobId) loadJobDetail(data.jobId).catch(() => {});
1008
+ }
1009
+
1010
+ async function loadHookStatus(slug) {
1011
+ if (!slug) return;
1012
+ try {
1013
+ const data = await api("GET", `/api/projects/${slug}/hook-status`);
1014
+ Object.assign(hookStatus, data);
1015
+ } catch (e) {
1016
+ Object.assign(hookStatus, { installed: false, path: "", message: e.message });
1017
+ }
1018
+ }
1019
+
1020
+ async function installHook(slug) {
1021
+ const data = await api("POST", `/api/projects/${slug}/hook-install`, { overwrite: false });
1022
+ Object.assign(hookStatus, data);
1023
+ }
1024
+
1025
+ async function uninstallHook(slug) {
1026
+ const data = await api("POST", `/api/projects/${slug}/hook-uninstall`);
1027
+ Object.assign(hookStatus, data);
1028
+ }
1029
+
1030
+ function resetForm() {
1031
+ Object.assign(form, { slug: "", displayName: "", localPath: "", gitPath: "", primaryLanguage: "", tagsStr: "", isReference: false, initNow: true, knowledgeLanguage: "zh-CN" });
1032
+ formError.value = "";
1033
+ formOk.value = "";
1034
+ }
1035
+
1036
+ async function addProject() {
1037
+ formError.value = "";
1038
+ formOk.value = "";
1039
+ submitting.value = true;
1040
+ try {
1041
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(form.slug)) throw new Error("Slug must be kebab-case.");
1042
+ const config = {
1043
+ displayName: form.displayName,
1044
+ localPath: form.localPath,
1045
+ gitPath: form.gitPath || form.localPath,
1046
+ primaryLanguage: form.primaryLanguage || "",
1047
+ tags: form.tagsStr.split(",").map(s => s.trim()).filter(Boolean),
1048
+ isReference: !!form.isReference,
1049
+ docConvention: "frontmatter-relations",
1050
+ kbPath: projectKbPath(form.slug),
1051
+ enabled: true,
1052
+ aiProfileId: aiConfig.defaultProfileId || "mock-agent",
1053
+ knowledgeLanguage: form.knowledgeLanguage || "zh-CN",
1054
+ kbSchemaVersion: "v1",
1055
+ goalStatus: "not-created"
1056
+ };
1057
+ await api("PUT", "/api/projects", { slug: form.slug, config });
1058
+ if (form.initNow) await api("POST", `/api/projects/${form.slug}/init`);
1059
+ formOk.value = `Imported ${form.slug}.`;
1060
+ await refreshAll();
1061
+ selectProject(form.slug);
1062
+ activeView.value = "dashboard";
1063
+ } catch (e) {
1064
+ formError.value = e.message;
1065
+ } finally {
1066
+ submitting.value = false;
1067
+ }
1068
+ }
1069
+
1070
+ async function loadRuns(slug) {
1071
+ if (!slug) return;
1072
+ try {
1073
+ const data = await api("GET", `/api/projects/${slug}/runs`);
1074
+ runs.value = (data.runs || []).sort((a, b) => String(b.startedAt || "").localeCompare(String(a.startedAt || "")));
1075
+ } catch {
1076
+ runs.value = [];
1077
+ }
1078
+ }
1079
+
1080
+ async function selectRun(runId) {
1081
+ selectedRunId.value = runId;
1082
+ await loadRunDetail(selectedSlug.value, runId);
1083
+ }
1084
+
1085
+ async function loadRunDetail(slug, runId) {
1086
+ const data = await api("GET", `/api/projects/${slug}/runs/${runId}`);
1087
+ selectedRun.value = data.run;
1088
+ drafts.value = data.drafts || [];
1089
+ for (const d of drafts.value) {
1090
+ if (!(d.path in draftSelection)) draftSelection[d.path] = true;
1091
+ }
1092
+ }
1093
+
1094
+ async function loadDraftPreview() {
1095
+ draftPreview.value = "";
1096
+ if (!selectedSlug.value || !selectedRunId.value || !previewDraftPath.value) return;
1097
+ const data = await api("GET", `/api/projects/${selectedSlug.value}/drafts/${selectedRunId.value}/raw?path=${encodeURIComponent(previewDraftPath.value)}`);
1098
+ draftPreview.value = data.content || "";
1099
+ }
1100
+
1101
+ async function applySelectedDrafts() {
1102
+ if (!selectedSlug.value || !selectedRunId.value) return;
1103
+ const selected = drafts.value.filter(d => draftSelection[d.path]);
1104
+ if (!selected.length) throw new Error("No selected drafts.");
1105
+ const draftPayload = [];
1106
+ for (const d of selected) {
1107
+ const raw = await api("GET", `/api/projects/${selectedSlug.value}/drafts/${selectedRunId.value}/raw?path=${encodeURIComponent(d.path)}`);
1108
+ draftPayload.push({ path: d.path, content: raw.content || "" });
1109
+ }
1110
+ await api("POST", `/api/projects/${selectedSlug.value}/drafts/${selectedRunId.value}/apply`, {
1111
+ drafts: draftPayload,
1112
+ allowGoalEdit: allowGoalEdit.value
1113
+ });
1114
+ await refreshAll();
1115
+ await loadRunDetail(selectedSlug.value, selectedRunId.value);
1116
+ }
1117
+
1118
+ async function rejectRun() {
1119
+ if (!selectedSlug.value || !selectedRunId.value) return;
1120
+ await api("POST", `/api/projects/${selectedSlug.value}/drafts/${selectedRunId.value}/reject`, { reason: "rejected from UI" });
1121
+ await loadRunDetail(selectedSlug.value, selectedRunId.value);
1122
+ }
1123
+
1124
+ async function applySchedule() {
1125
+ const data = await api("PUT", "/api/schedule", { frequency: freq.frequency, time: freq.time, runner: freq.runner });
1126
+ scheduleMsg.value = data.ok ? `Updated: ${data.mode} / ${data.runner || freq.runner}` : "Failed";
1127
+ await refreshAll();
1128
+ }
1129
+
1130
+ async function deleteSchedule() {
1131
+ const data = await api("PUT", "/api/schedule", { frequency: "off" });
1132
+ scheduleMsg.value = data.ok ? "Schedule removed." : "Failed";
1133
+ await refreshAll();
1134
+ }
1135
+
1136
+ function toggleTheme() {
1137
+ theme.value = theme.value === "dark" ? "light" : "dark";
1138
+ localStorage.setItem("kb-theme", theme.value);
1139
+ }
1140
+
1141
+ onMounted(() => {
1142
+ document.documentElement.lang = uiLanguage.value === "zh" ? "zh-CN" : "en";
1143
+ refreshAll();
1144
+ });
1145
+ const pollTimer = setInterval(refreshAll, 30000);
1146
+ onUnmounted(() => clearInterval(pollTimer));
1147
+
1148
+ return {
1149
+ kbRoot, projects, schedule, lastRun, runningJobs, jobHistory, selectedJob, logOutput, logTitle,
1150
+ loading, pollError, activeView, selectedSlug, selectedProject, pageTitle, projectList, summaryCards, navItems,
1151
+ theme, uiLanguage, t, toggleTheme, form, submitting, formError, formOk, addProject, resetForm, busySlug, initProject,
1152
+ freq, scheduleMsg, applySchedule, deleteSchedule,
1153
+ runs, selectedRunId, selectedRun, drafts, draftSelection, previewDraftPath, draftPreview, allowGoalEdit,
1154
+ aiConfig, aiProfilesText, adapters, aiMessage, selectedAiProfileId, selectedKnowledgeLanguage,
1155
+ hookStatus, actionMessage,
1156
+ selectProject, isKbInit, isProjectRunning, repoStatusLevel, projectStatusClass, projectIssues,
1157
+ kbUrl, goalUrl, displayProjectKbPath, formatTime,
1158
+ refreshAll, loadJobs, loadJobDetail, loadAiProfiles, saveAiProfiles, setProjectAiProfile, saveProjectSettings,
1159
+ validateGit, scanProject, validateKb, migrateV2, runJob, loadHookStatus, installHook, uninstallHook,
1160
+ loadRuns, selectRun, loadRunDetail, loadDraftPreview, applySelectedDrafts, rejectRun
1161
+ };
1162
+ }
1163
+ }).mount("#app");
1164
+ </script>
1165
+ </body>
1166
+ </html>