From 8897732756b4393634c9f65b6487550b87326d76 Mon Sep 17 00:00:00 2001 From: Sebastian Lim Date: Sat, 18 Jul 2026 19:40:44 +0800 Subject: [PATCH 01/12] refactor(study): remove catalog from core home --- .../components/product/StudyProductHome.vue | 46 ++----------------- 1 file changed, 3 insertions(+), 43 deletions(-) diff --git a/apps/kimi-web/src/study/components/product/StudyProductHome.vue b/apps/kimi-web/src/study/components/product/StudyProductHome.vue index 64509851..9885c631 100644 --- a/apps/kimi-web/src/study/components/product/StudyProductHome.vue +++ b/apps/kimi-web/src/study/components/product/StudyProductHome.vue @@ -1,6 +1,5 @@ - + - - - - From a7e4d37ee5b42c32a50e59e1d45e794dd6d4b1d3 Mon Sep 17 00:00:00 2001 From: microseyuyu Date: Sat, 18 Jul 2026 20:34:53 +0800 Subject: [PATCH 04/12] fix(study): restore green CI baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ThinkingLevel | undefined type errors in App.vue and Composer.vue by falling back to model defaultThinkingLevelFor when no preference set - Replace unregistered book icon with file-text in StudyProductHome.vue - Add makeDirectory to KimiWebApi interface and daemon client (POST /sessions/{id}/fs:mkdir, FS_ALREADY_EXISTS as allowCode) - Replace hardcoded style values with design tokens in study components: font-weight: 600/700 → var(--weight-semibold) border-radius: 3px → var(--radius-xs) border-radius: 50px !important → var(--radius-full) z-index: 20 → 1 - Fix no-confusing-void-expression lint error in study-runtime-foundation test - Add 4 auto-Quick behavior tests: startCourse once, upload failure guard, selectMode failure recovery, historical course open unaffected --- apps/kimi-web/src/App.vue | 6 +- apps/kimi-web/src/api/daemon/client.ts | 15 +++++ apps/kimi-web/src/api/types.ts | 2 + .../kimi-web/src/components/chat/Composer.vue | 3 +- .../src/study/components/StudyCourseList.vue | 2 +- .../src/study/components/StudyGenerator.vue | 18 +++--- .../src/study/components/StudyLearner.vue | 10 ++-- .../components/product/StudyProductHome.vue | 2 +- .../test/study-runtime-foundation.test.ts | 57 ++++++++++++++++++- 9 files changed, 93 insertions(+), 22 deletions(-) diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index d71852bc..8bca4045 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -38,7 +38,7 @@ import type { SwarmMember } from './composables/swarmGroups'; import ServerAuthDialog from './components/ServerAuthDialog.vue'; import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; import type { AppConfig, ThinkingLevel } from './api/types'; -import { commitLevel, effectiveThinkingLevel, segmentsFor } from './lib/modelThinking'; +import { commitLevel, defaultThinkingLevelFor, effectiveThinkingLevel, segmentsFor } from './lib/modelThinking'; import { stripSkillPrefix } from './lib/slashCommands'; import Button from './components/ui/Button.vue'; import IconButton from './components/ui/IconButton.vue'; @@ -119,7 +119,7 @@ function nextThinkingLevel(current: ThinkingLevel | undefined): ThinkingLevel { // No stored preference means the model default is in effect — cycle from // there; a level the model doesn't declare (indexOf → -1) starts the cycle // at the first segment. - const idx = segs.indexOf(effectiveThinkingLevel(model, current)); + const idx = segs.indexOf(effectiveThinkingLevel(model, current ?? defaultThinkingLevelFor(model))); const next = segs[(idx + 1) % segs.length] ?? segs[0] ?? 'off'; return commitLevel(model, next); } @@ -129,7 +129,7 @@ function nextThinkingLevel(current: ThinkingLevel | undefined): ThinkingLevel { // will actually run, not a blank. const statusPanelThinking = computed(() => { const model = client.models.value.find((m) => m.id === client.status.value.modelId); - return effectiveThinkingLevel(model, client.thinking.value); + return effectiveThinkingLevel(model, client.thinking.value ?? defaultThinkingLevelFor(model)); }); // First-run onboarding (language + welcome greeting). Shown until the user diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 5ff57e74..08775c5a 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -933,6 +933,21 @@ export class DaemonKimiWebApi implements KimiWebApi { }; } + /** POST /sessions/{id}/fs:mkdir — create a directory. FS_ALREADY_EXISTS (40919) + * is treated as success (the directory is ready to use). */ + async makeDirectory( + sessionId: string, + input: { path: string }, + ): Promise<{ made: boolean; path: string }> { + const data = await this.http.post<{ made: boolean; path: string }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:mkdir`, + { path: input.path }, + { allowCodes: [40919] }, // FS_ALREADY_EXISTS + ); + return { made: data?.made ?? false, path: input.path }; + } + + async readFile( sessionId: string, input: { path: string; offset?: number; length?: number }, diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index cbcde2d0..e16acbd3 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -706,6 +706,8 @@ export interface KimiWebApi { getTerminal(sessionId: string, terminalId: string): Promise; closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }>; listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean }>; + /** Create a directory. Returns FS_ALREADY_EXISTS (40919) when the path already exists — callers should treat that as success. */ + makeDirectory(sessionId: string, input: { path: string }): Promise<{ made: boolean; path: string }>; readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>; searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>; diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 111c06fd..f3e33dcd 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -10,6 +10,7 @@ import type { FileItem } from './MentionMenu.vue'; import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types'; import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types'; import { + defaultThinkingLevelFor, commitLevel, effectiveThinkingLevel, effortLabel, @@ -617,7 +618,7 @@ const thinkingSegments = computed(() => segmentsFor(currentModel.value)); // the model default, which is what the daemon will resolve for the prompt. A // level the model doesn't declare highlights no segment but still shows in the // suffix. -const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking)); +const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking ?? defaultThinkingLevelFor(currentModel.value))); const activeThinkingSegment = computed(() => { const segs = thinkingSegments.value; return segs.includes(thinkingLevel.value) ? thinkingLevel.value : ''; diff --git a/apps/kimi-web/src/study/components/StudyCourseList.vue b/apps/kimi-web/src/study/components/StudyCourseList.vue index aed50bff..ec91823d 100644 --- a/apps/kimi-web/src/study/components/StudyCourseList.vue +++ b/apps/kimi-web/src/study/components/StudyCourseList.vue @@ -133,6 +133,6 @@ function statusText(course: CourseSummary): string { .course-status-review { color: var(--color-accent); - font-weight: 600; + font-weight: var(--weight-semibold); } diff --git a/apps/kimi-web/src/study/components/StudyGenerator.vue b/apps/kimi-web/src/study/components/StudyGenerator.vue index 2b1d17ef..14e9d96d 100644 --- a/apps/kimi-web/src/study/components/StudyGenerator.vue +++ b/apps/kimi-web/src/study/components/StudyGenerator.vue @@ -375,7 +375,7 @@ function openLesson(file: string): void { align-items: center; gap: var(--space-2); font-size: 16px; - font-weight: 600; + font-weight: var(--weight-semibold); margin: 0; } @@ -388,14 +388,14 @@ function openLesson(file: string): void { .reading-bar { height: 6px; - border-radius: 3px; + border-radius: var(--radius-xs); background: var(--color-surface-sunken); overflow: hidden; } .reading-bar-fill { height: 100%; - border-radius: 3px; + border-radius: var(--radius-xs); background: var(--color-accent); transition: width 0.4s ease; } @@ -423,7 +423,7 @@ function openLesson(file: string): void { .course-title { font-size: 22px; - font-weight: 700; + font-weight: var(--weight-semibold); margin: 0 0 var(--space-3); line-height: 1.3; } @@ -489,7 +489,7 @@ function openLesson(file: string): void { cursor: pointer; list-style: none; font-size: 15px; - font-weight: 600; + font-weight: var(--weight-semibold); } .chapter-summary::-webkit-details-marker { @@ -549,7 +549,7 @@ function openLesson(file: string): void { .published-title { font-size: 13px; - font-weight: 600; + font-weight: var(--weight-semibold); color: var(--color-success); margin-bottom: var(--space-2); } @@ -601,7 +601,7 @@ function openLesson(file: string): void { .chat-rail-title { flex: 1; min-width: 0; - font-weight: 600; + font-weight: var(--weight-semibold); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -617,8 +617,8 @@ function openLesson(file: string): void { /* Coursebox generate button: centred pill, min(400px,100%) × 44, radius 50. */ .primary-wide { width: min(400px, 100%); - border-radius: 50px !important; - font-weight: 600 !important; + border-radius: var(--radius-full); + font-weight: var(--weight-semibold) !important; transition: background 0.15s, transform 0.1s !important; diff --git a/apps/kimi-web/src/study/components/StudyLearner.vue b/apps/kimi-web/src/study/components/StudyLearner.vue index ade48f3c..0077a9b0 100644 --- a/apps/kimi-web/src/study/components/StudyLearner.vue +++ b/apps/kimi-web/src/study/components/StudyLearner.vue @@ -254,7 +254,7 @@ function backToOutline(): void { .toc-title { font-size: 12px; - font-weight: 600; + font-weight: var(--weight-semibold); color: var(--color-text-faint); padding: 0 6px; } @@ -295,7 +295,7 @@ function backToOutline(): void { .toc-item-active { background: var(--color-selected); color: var(--color-text); - font-weight: 600; + font-weight: var(--weight-semibold); } .toc-no { @@ -334,7 +334,7 @@ function backToOutline(): void { .lesson-band-label { flex: none; - font-weight: 600; + font-weight: var(--weight-semibold); color: var(--color-accent); } @@ -367,7 +367,7 @@ function backToOutline(): void { background: var(--color-bg); border-left: 1px solid var(--color-line); box-shadow: -8px 0 24px rgb(0 0 0 / 8%); - z-index: 20; + z-index: 1; } .tutor-slide-enter-active, @@ -391,7 +391,7 @@ function backToOutline(): void { .tutor-title { font-size: 14px; - font-weight: 600; + font-weight: var(--weight-semibold); } .tutor-context { diff --git a/apps/kimi-web/src/study/components/product/StudyProductHome.vue b/apps/kimi-web/src/study/components/product/StudyProductHome.vue index 9885c631..fddc5654 100644 --- a/apps/kimi-web/src/study/components/product/StudyProductHome.vue +++ b/apps/kimi-web/src/study/components/product/StudyProductHome.vue @@ -96,7 +96,7 @@ function formatUpdated(iso: string): string { @keydown.enter="emit('open', course.courseId)" >
- +
{{ course.title }} {{ formatUpdated(course.updatedAt) }} diff --git a/apps/kimi-web/test/study-runtime-foundation.test.ts b/apps/kimi-web/test/study-runtime-foundation.test.ts index 607ba756..1804456f 100644 --- a/apps/kimi-web/test/study-runtime-foundation.test.ts +++ b/apps/kimi-web/test/study-runtime-foundation.test.ts @@ -65,6 +65,8 @@ class FakeRuntime implements StudyRuntimePort { watchDelay: Promise | undefined; answerDelay: Promise | undefined; generateError: Error | undefined; + uploadImpl: (() => Promise<{ fileId: string; name: string; mediaType: string; size: number; sourceRevision: string }>) | undefined; + startImpl: ((input: StartCourseInput) => Promise) | undefined; uploaded = { fileId: 'file-1', name: 'Book.pdf', @@ -79,8 +81,9 @@ class FakeRuntime implements StudyRuntimePort { async listCourses(): Promise { return this.currentBinding === undefined ? [] : [this.currentBinding]; } - async uploadMaterial(): Promise { return this.uploaded; } + async uploadMaterial(): Promise { if (this.uploadImpl !== undefined) return this.uploadImpl(); return this.uploaded; } async startCourse(input: StartCourseInput): Promise { + if (this.startImpl !== undefined) { const result = await this.startImpl(input); this.startCalls.push(input); this.currentBinding = result; this.snapshots.set(input.snapshot.courseId, input.snapshot); return result; } this.startCalls.push(input); this.currentBinding = binding(input.snapshot.courseId); // The teaching engine writes its first artifacts asynchronously; by the @@ -494,7 +497,7 @@ describe('StudyProductController concurrency guards', () => { expect(runtime.loadCalls).toHaveLength(loadsAfterOpen); runtime.handlersByCourse.get('course-b')?.onArtifactChanged(); - await vi.waitFor(() => expect(runtime.loadCalls).toHaveLength(loadsAfterOpen + 1)); + await vi.waitFor(() => { expect(runtime.loadCalls).toHaveLength(loadsAfterOpen + 1); }); expect(runtime.loadCalls.at(-1)).toBe('course-b'); }); @@ -606,4 +609,54 @@ describe('StudyProductController concurrency guards', () => { expect(controller.view.stage).toBe('error'); expect(controller.view.issues[0]?.code).toBe('artifact_not_found'); }); + + it('invokes startCourse exactly once for upload + quick mode', async () => { + const runtime = new FakeRuntime(); + const controller = makeController(runtime); + await controller.upload(new File(['book'], 'Book.pdf')); + expect(controller.view.stage).toBe('mode_selection'); + + await controller.selectMode('quick'); + expect(runtime.startCalls).toHaveLength(1); + + // Re-calling selectMode with quick again is rejected by the domain event + await expect(controller.selectMode('quick')).rejects.toThrow(); + expect(runtime.startCalls).toHaveLength(1); + expect(controller.view.stage).toBe('working'); // Sync domain rejection before try/catch + }); + + it('does not call startCourse when the upload itself fails', async () => { + const runtime = new FakeRuntime(); + runtime.uploadImpl = () => { throw new Error('upload failure'); }; + const controller = makeController(runtime); + await expect(controller.upload(new File(['book'], 'Book.pdf'))).rejects.toThrow('upload failure'); + expect(runtime.startCalls).toHaveLength(0); + expect(controller.view.stage).toBe('error'); + }); + + it('preserves a recoverable error state when selectMode fails', async () => { + const runtime = new FakeRuntime(); + runtime.startImpl = () => { throw new Error('start refused'); }; + const controller = makeController(runtime); + await controller.upload(new File(['book'], 'Book.pdf')); + await expect(controller.selectMode('quick')).rejects.toThrow('start refused'); + + expect(controller.view.stage).toBe('error'); + expect(controller.view.issues).toHaveLength(1); + expect(controller.view.snapshot).not.toBeNull(); + }); + + it('keeps historical course open path unchanged after auto-Quick changes', async () => { + const runtime = new FakeRuntime(); + runtime.snapshots.set('course-historic', quickSnapshot('course-historic')); + runtime.resumeImpl = async (courseId) => binding(courseId); + const controller = makeController(runtime); + + const opened = await controller.open('course-historic'); + expect(opened).toBe(true); + expect(controller.view.binding?.courseId).toBe('course-historic'); + expect(controller.view.snapshot?.courseId).toBe('course-historic'); + expect(runtime.startCalls).toHaveLength(0); + }); + }); From 0fe464c04b196e15f8f8fc37b996a4a4a7889c1d Mon Sep 17 00:00:00 2001 From: microseyuyu Date: Sat, 18 Jul 2026 21:44:48 +0800 Subject: [PATCH 05/12] feat(study): harden outline revision lifecycle --- apps/kimi-web/src/study/foundation.ts | 2 + .../study/product/studyProductController.ts | 169 ++++++++++- .../test/study-runtime-foundation.test.ts | 272 +++++++++++++++++- 3 files changed, 425 insertions(+), 18 deletions(-) diff --git a/apps/kimi-web/src/study/foundation.ts b/apps/kimi-web/src/study/foundation.ts index 4f140921..6e7f6363 100644 --- a/apps/kimi-web/src/study/foundation.ts +++ b/apps/kimi-web/src/study/foundation.ts @@ -43,6 +43,8 @@ export type { LessonSource, LessonStatus } from './domain/lessonDocument'; export type { TutorExchange } from './domain/tutorThread'; export type { + StudyPlanChangeState, + StudyPlanChangeStatus, StudyProductStage, StudyProductView, } from './product/studyProductController'; diff --git a/apps/kimi-web/src/study/product/studyProductController.ts b/apps/kimi-web/src/study/product/studyProductController.ts index 834880da..8ca97c06 100644 --- a/apps/kimi-web/src/study/product/studyProductController.ts +++ b/apps/kimi-web/src/study/product/studyProductController.ts @@ -30,6 +30,21 @@ export type StudyProductStage = | 'ready' | 'error'; +export type StudyPlanChangeStatus = + | 'idle' + | 'submitting' + | 'waiting' + | 'succeeded' + | 'failed'; + +export interface StudyPlanChangeState { + readonly status: StudyPlanChangeStatus; + /** The immutable plan revision named by the learner's request. */ + readonly baseRevision?: string; + /** The authoritative replacement revision after the Skill finishes. */ + readonly resultRevision?: string; +} + export interface StudyProductView { readonly stage: StudyProductStage; readonly snapshot: CourseSnapshot | null; @@ -38,6 +53,7 @@ export interface StudyProductView { readonly readiness: StudyRuntimeReadiness | null; readonly connected: boolean; readonly issues: readonly ContractIssue[]; + readonly planChange: StudyPlanChangeState; } type Listener = (view: StudyProductView) => void; @@ -53,6 +69,18 @@ export interface StudyProductControllerOptions { } const DEFAULT_MISSING_RETRY_DELAYS: readonly number[] = [300, 700]; +const IDLE_PLAN_CHANGE: StudyPlanChangeState = { status: 'idle' }; + +interface PendingPlanChange { + readonly courseId: string; + readonly baseRevision: string; + readonly instruction: string; + submission: Promise; +} + +function isPlanChangeBusy(change: StudyPlanChangeState): boolean { + return change.status === 'submitting' || change.status === 'waiting'; +} function delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); @@ -77,6 +105,7 @@ export class StudyProductController { readiness: null, connected: false, issues: [], + planChange: IDLE_PLAN_CHANGE, }; private readonly listeners = new Set(); private readonly createCourseId: () => string; @@ -89,6 +118,13 @@ export class StudyProductController { * results arriving from an older context are dropped instead of committed. */ private contextEpoch = 0; + /** Latest snapshot read in the active course context wins. */ + private snapshotLoadEpoch = 0; + private pendingPlanChange: PendingPlanChange | undefined; + /** Set only by a real session-idle event while a plan change is pending. */ + private planChangeIdle: + | { readonly courseId: string; readonly baseRevision: string } + | undefined; constructor( private readonly runtime: StudyRuntimePort, @@ -137,12 +173,13 @@ export class StudyProductController { binding: null, question: null, issues: [], + planChange: IDLE_PLAN_CHANGE, }); } async upload(file: File): Promise { const epoch = this.nextContext(); - this.update({ stage: 'uploading', issues: [] }); + this.update({ stage: 'uploading', issues: [], planChange: IDLE_PLAN_CHANGE }); try { const material = await this.runtime.uploadMaterial(file); if (!this.isCurrent(epoch)) throw new Error('Study upload was superseded.'); @@ -185,7 +222,14 @@ export class StudyProductController { const epoch = this.nextContext(); this.uploadedMaterial = undefined; const snapshot = createCatalogCourse(this.createCourseId(), material, this.now()); - this.update({ stage: 'starting', snapshot, binding: null, question: null, issues: [] }); + this.update({ + stage: 'starting', + snapshot, + binding: null, + question: null, + issues: [], + planChange: IDLE_PLAN_CHANGE, + }); try { const binding = await this.runtime.startCourse({ snapshot, catalogMaterial: material }); if (!this.isCurrent(epoch, snapshot.courseId)) return; @@ -204,7 +248,7 @@ export class StudyProductController { if (binding === undefined) return false; if (!this.isCurrent(epoch)) return false; this.uploadedMaterial = binding.uploadedMaterial; - this.update({ binding, stage: 'working', issues: [] }); + this.update({ binding, stage: 'working', issues: [], planChange: IDLE_PLAN_CHANGE }); await this.attachWatcher(courseId, epoch); await this.refreshUntilSettled(courseId, epoch); return true; @@ -223,9 +267,31 @@ export class StudyProductController { courseId: string, epoch: number, ): Promise<'ready' | 'missing' | 'invalid' | 'stale'> { + const loadEpoch = ++this.snapshotLoadEpoch; const loaded = await this.runtime.loadSnapshot(courseId); - if (!this.isCurrent(epoch, courseId)) return 'stale'; + if (!this.isCurrent(epoch, courseId) || loadEpoch !== this.snapshotLoadEpoch) return 'stale'; if (loaded.status === 'ready') { + const currentChange = this.viewState.planChange; + let planChange = currentChange; + if (isPlanChangeBusy(currentChange) && currentChange.baseRevision !== undefined) { + const nextRevision = loaded.snapshot.plan.status === 'ready' + ? loaded.snapshot.plan.revision + : undefined; + if (nextRevision !== undefined && nextRevision !== currentChange.baseRevision) { + planChange = { + status: 'succeeded', + baseRevision: currentChange.baseRevision, + resultRevision: nextRevision, + }; + this.pendingPlanChange = undefined; + this.planChangeIdle = undefined; + } else if (this.failPlanChangeAtIdle(courseId, currentChange)) { + // Runtime only raises onArtifactChanged when the session becomes + // idle. Reaching idle without a new ready revision is a recoverable + // plan-edit failure; the previous authoritative snapshot stays put. + planChange = this.viewState.planChange; + } + } this.update({ snapshot: loaded.snapshot, stage: this.viewState.question === null @@ -233,10 +299,15 @@ export class StudyProductController { || loaded.snapshot.generation.status === 'partially_ready' ? 'ready' : 'working') : 'question', issues: [], + planChange, }); return 'ready'; } - if (loaded.status === 'missing') return 'missing'; + if (loaded.status === 'missing') { + this.failPlanChangeAtIdle(courseId, this.viewState.planChange); + return 'missing'; + } + if (this.failPlanChangeAtIdle(courseId, this.viewState.planChange)) return 'invalid'; this.update({ stage: 'error', issues: loaded.issues }); return 'invalid'; } @@ -302,11 +373,21 @@ export class StudyProductController { async generate(): Promise { const snapshot = this.requireSnapshot(); - if (!snapshot.canGenerate || snapshot.plan.revision === undefined) { + const planRevision = snapshot.plan.revision; + if (planRevision === undefined) { + throw new Error('The current evidence and plan revisions are not ready for generation.'); + } + if (snapshot.generation.planRevision === planRevision + && (snapshot.generation.status === 'generating' + || snapshot.generation.status === 'partially_ready' + || snapshot.generation.status === 'ready')) { + // A double-click or replayed event confirms the same revision only once. + return; + } + if (!snapshot.canGenerate || isPlanChangeBusy(this.viewState.planChange)) { throw new Error('The current evidence and plan revisions are not ready for generation.'); } const epoch = this.contextEpoch; - const planRevision = snapshot.plan.revision; const next = applyCourseEvent(snapshot, { type: 'generation_updated', generation: { @@ -336,10 +417,13 @@ export class StudyProductController { } async upgradeToDeep(): Promise { + if (isPlanChangeBusy(this.viewState.planChange)) { + throw new Error('Wait for the current outline change before deepening the course.'); + } const current = this.requireSnapshot(); const epoch = this.nextContext(); const snapshot = applyCourseEvent(current, { type: 'upgrade_requested' }, this.now()); - this.update({ snapshot, stage: 'starting', issues: [] }); + this.update({ snapshot, stage: 'starting', issues: [], planChange: IDLE_PLAN_CHANGE }); try { const binding = await this.runtime.startCourse({ snapshot, @@ -394,7 +478,49 @@ export class StudyProductController { throw new Error('Only the current ready outline revision can be revised.'); } if (text.length === 0) throw new Error('Outline change request is empty.'); - await this.runtime.requestPlanChange(snapshot.courseId, snapshot.plan.revision, text); + + const baseRevision = snapshot.plan.revision; + const active = this.pendingPlanChange; + if (isPlanChangeBusy(this.viewState.planChange)) { + if (active?.courseId === snapshot.courseId + && active.baseRevision === baseRevision + && active.instruction === text) { + await active.submission; + return; + } + throw new Error('Another outline change is still in progress.'); + } + + const epoch = this.contextEpoch; + const request: PendingPlanChange = { + courseId: snapshot.courseId, + baseRevision, + instruction: text, + submission: Promise.resolve(), + }; + this.pendingPlanChange = request; + this.planChangeIdle = undefined; + this.update({ + planChange: { status: 'submitting', baseRevision }, + }); + request.submission = Promise.resolve().then(() => + this.runtime.requestPlanChange(snapshot.courseId, baseRevision, text)); + + try { + await request.submission; + if (this.pendingPlanChange === request + && this.isCurrent(epoch, snapshot.courseId) + && this.viewState.planChange.status === 'submitting') { + this.update({ planChange: { status: 'waiting', baseRevision } }); + } + } catch (error) { + if (this.pendingPlanChange === request && this.isCurrent(epoch, snapshot.courseId)) { + this.pendingPlanChange = undefined; + this.planChangeIdle = undefined; + this.update({ planChange: { status: 'failed', baseRevision } }); + } + throw error; + } } dispose(): void { @@ -420,6 +546,10 @@ export class StudyProductController { }, onArtifactChanged: () => { if (!this.isCurrent(epoch, courseId)) return; + const change = this.viewState.planChange; + if (isPlanChangeBusy(change) && change.baseRevision !== undefined) { + this.planChangeIdle = { courseId, baseRevision: change.baseRevision }; + } void this.refreshSnapshot(courseId, epoch); }, onConnectionChange: (connected) => { @@ -445,9 +575,30 @@ export class StudyProductController { /** Begin a new async context; results from older contexts get dropped. */ private nextContext(): number { this.contextEpoch += 1; + this.snapshotLoadEpoch += 1; + this.pendingPlanChange = undefined; + this.planChangeIdle = undefined; return this.contextEpoch; } + private failPlanChangeAtIdle( + courseId: string, + change: StudyPlanChangeState, + ): boolean { + if (!isPlanChangeBusy(change) + || change.baseRevision === undefined + || this.planChangeIdle?.courseId !== courseId + || this.planChangeIdle.baseRevision !== change.baseRevision) { + return false; + } + this.pendingPlanChange = undefined; + this.planChangeIdle = undefined; + this.update({ + planChange: { status: 'failed', baseRevision: change.baseRevision }, + }); + return true; + } + /** True when an async result still belongs to the live course context. */ private isCurrent(epoch: number, courseId?: string): boolean { if (epoch !== this.contextEpoch) return false; diff --git a/apps/kimi-web/test/study-runtime-foundation.test.ts b/apps/kimi-web/test/study-runtime-foundation.test.ts index 1804456f..76c4bd84 100644 --- a/apps/kimi-web/test/study-runtime-foundation.test.ts +++ b/apps/kimi-web/test/study-runtime-foundation.test.ts @@ -32,6 +32,13 @@ import { normalizeStudyQuestion } from '../src/study/runtime/studyRuntime'; const NOW = '2026-07-17T00:00:00.000Z'; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + class MemoryStorage implements StudyStorage { readonly values = new Map(); getItem(key: string): string | null { return this.values.get(key) ?? null; } @@ -65,6 +72,22 @@ class FakeRuntime implements StudyRuntimePort { watchDelay: Promise | undefined; answerDelay: Promise | undefined; generateError: Error | undefined; + generateDelay: Promise | undefined; + readonly generateCalls: Array<{ + readonly courseId: string; + readonly planRevision: string; + }> = []; + planChangeImpl: + | ((courseId: string, planRevision: string, instruction: string) => Promise) + | undefined; + readonly planChangeCalls: Array<{ + readonly courseId: string; + readonly planRevision: string; + readonly instruction: string; + }> = []; + loadSnapshotImpl: + | ((courseId: string) => Promise) + | undefined; uploadImpl: (() => Promise<{ fileId: string; name: string; mediaType: string; size: number; sourceRevision: string }>) | undefined; startImpl: ((input: StartCourseInput) => Promise) | undefined; uploaded = { @@ -97,6 +120,7 @@ class FakeRuntime implements StudyRuntimePort { } async loadSnapshot(courseId: string): Promise { this.loadCalls.push(courseId); + if (this.loadSnapshotImpl !== undefined) return this.loadSnapshotImpl(courseId); const snapshot = this.snapshots.get(courseId); return snapshot === undefined ? { status: 'missing' } : { status: 'ready', snapshot }; } @@ -113,7 +137,9 @@ class FakeRuntime implements StudyRuntimePort { if (this.answerDelay !== undefined) await this.answerDelay; } async dismissQuestion(): Promise {} - async requestGeneration(): Promise { + async requestGeneration(courseId: string, planRevision: string): Promise { + this.generateCalls.push({ courseId, planRevision }); + if (this.generateDelay !== undefined) await this.generateDelay; if (this.generateError !== undefined) throw this.generateError; } async readCourseText(): Promise { return { status: 'missing' }; } @@ -121,7 +147,16 @@ class FakeRuntime implements StudyRuntimePort { async listCatalog(): Promise { return []; } async sendTutorMessage(): Promise {} async listTutorMessages(): Promise { return []; } - async requestPlanChange(): Promise {} + async requestPlanChange( + courseId: string, + planRevision: string, + instruction: string, + ): Promise { + this.planChangeCalls.push({ courseId, planRevision, instruction }); + if (this.planChangeImpl !== undefined) { + await this.planChangeImpl(courseId, planRevision, instruction); + } + } } describe('StudyCourseRegistry', () => { @@ -321,6 +356,39 @@ describe('KimiStudyRuntime pagination', () => { )).resolves.toMatchObject({ id: 'message-1' }); expect(listMessages).toHaveBeenCalledTimes(2); }); + + it('coalesces and persists the same plan-edit operation id', async () => { + const gate = deferred<{ promptId: string; userMessageId: string }>(); + const listMessages: KimiWebApi['listMessages'] = vi.fn(async () => ({ + items: [], + hasMore: false, + })); + const submitPrompt: KimiWebApi['submitPrompt'] = vi.fn(() => gate.promise); + const runtime = runtimeWith({ listMessages, submitPrompt }, true); + + const first = runtime.requestPlanChange( + 'course-12345678', + 'plan-v1', + '缩减为六节课', + ); + const duplicate = runtime.requestPlanChange( + 'course-12345678', + 'plan-v1', + '缩减为六节课', + ); + await vi.waitFor(() => { expect(submitPrompt).toHaveBeenCalledTimes(1); }); + gate.resolve({ promptId: 'prompt-plan-edit-1', userMessageId: 'message-plan-edit-1' }); + await Promise.all([first, duplicate]); + + await runtime.requestPlanChange('course-12345678', 'plan-v1', '缩减为六节课'); + expect(submitPrompt).toHaveBeenCalledTimes(1); + expect(submitPrompt).toHaveBeenCalledWith('session-1', expect.objectContaining({ + metadata: expect.objectContaining({ + kimiStudyCourseId: 'course-12345678', + kimiStudyPlanRevision: 'plan-v1', + }), + })); + }); }); describe('StudyProductController', () => { @@ -395,13 +463,6 @@ describe('generation revision gate', () => { }); describe('StudyProductController concurrency guards', () => { - function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); - return { promise, resolve, reject }; - } - function quickSnapshot(courseId: string): CourseSnapshot { const draft = createUploadCourse(courseId, { fileId: 'file-1', @@ -413,6 +474,41 @@ describe('StudyProductController concurrency guards', () => { return applyCourseEvent(draft, { type: 'mode_selected', mode: 'quick' }, NOW); } + function readySnapshot(courseId: string, planRevision = 'plan-v1'): CourseSnapshot { + let ready = quickSnapshot(courseId); + ready = applyCourseEvent(ready, { + type: 'source_updated', + source: { + ...ready.source, + status: 'ready', + evidenceLevel: 'survey', + quickSurveyRevision: 'survey-v1', + }, + approval: { + actor: 'auto_policy', + policyRevision: 'auto-v1', + approvedRevision: 'survey-v1', + approvedAt: NOW, + }, + }, NOW); + ready = applyCourseEvent(ready, { + type: 'mission_updated', + mission: { status: 'ready', revision: 1, questionsAsked: 1, summary: '通过考试。' }, + }, NOW); + return applyCourseEvent(ready, { + type: 'plan_updated', + plan: { + status: 'ready', + revision: planRevision, + basedOnSourceRevision: ready.source.revision, + basedOnMissionRevision: ready.mission.revision, + chapterCount: 3, + pageCount: 6, + quizCount: 1, + }, + }, NOW); + } + function readyQuestion(questionId: string): StudyQuestion { const normalized = normalizeStudyQuestion({ questionId, @@ -501,6 +597,144 @@ describe('StudyProductController concurrency guards', () => { expect(runtime.loadCalls.at(-1)).toBe('course-b'); }); + it('keeps the newest snapshot when an older same-course read resolves late', async () => { + const runtime = new FakeRuntime(); + const original = readySnapshot('course-12345678'); + const revised = applyCourseEvent(original, { + type: 'plan_updated', + plan: { + ...original.plan, + revision: 'plan-v2', + chapterCount: 4, + }, + }, NOW); + runtime.snapshots.set('course-12345678', original); + runtime.resumeImpl = async (courseId) => binding(courseId); + const controller = makeController(runtime); + await controller.open('course-12345678'); + + const older = deferred(); + const newer = deferred(); + let reads = 0; + runtime.loadSnapshotImpl = async () => { + reads += 1; + return reads === 1 ? older.promise : newer.promise; + }; + const slowRefresh = controller.refresh(); + const fastRefresh = controller.refresh(); + newer.resolve({ status: 'ready', snapshot: revised }); + await fastRefresh; + older.resolve({ status: 'ready', snapshot: original }); + await slowRefresh; + + expect(controller.view.snapshot?.plan.revision).toBe('plan-v2'); + expect(controller.view.snapshot?.plan.chapterCount).toBe(4); + }); + + it('coalesces duplicate outline changes and waits for a new authoritative revision', async () => { + const runtime = new FakeRuntime(); + const original = readySnapshot('course-12345678'); + runtime.snapshots.set('course-12345678', original); + runtime.resumeImpl = async (courseId) => binding(courseId); + const controller = makeController(runtime); + await controller.open('course-12345678'); + + const gate = deferred(); + runtime.planChangeImpl = async () => gate.promise; + const first = controller.requestPlanChange('缩减为六节课'); + const duplicate = controller.requestPlanChange(' 缩减为六节课 '); + await vi.waitFor(() => { + expect(runtime.planChangeCalls).toHaveLength(1); + expect(controller.view.planChange.status).toBe('submitting'); + }); + await expect(controller.requestPlanChange('先讲案例再讲原理')) + .rejects.toThrow('still in progress'); + gate.resolve(undefined); + await Promise.all([first, duplicate]); + expect(controller.view.planChange).toEqual({ + status: 'waiting', + baseRevision: 'plan-v1', + }); + + const revised = applyCourseEvent(original, { + type: 'plan_updated', + plan: { + ...original.plan, + revision: 'plan-v2', + pageCount: 5, + }, + }, NOW); + runtime.snapshots.set('course-12345678', revised); + runtime.handlers?.onArtifactChanged(); + await vi.waitFor(() => { expect(controller.view.planChange.status).toBe('succeeded'); }); + + expect(controller.view.planChange).toEqual({ + status: 'succeeded', + baseRevision: 'plan-v1', + resultRevision: 'plan-v2', + }); + expect(controller.view.snapshot?.plan.revision).toBe('plan-v2'); + expect(runtime.startCalls).toHaveLength(0); + }); + + it('keeps the last valid outline on failure and allows an exact retry', async () => { + const runtime = new FakeRuntime(); + const original = readySnapshot('course-12345678'); + runtime.snapshots.set('course-12345678', original); + runtime.resumeImpl = async (courseId) => binding(courseId); + const controller = makeController(runtime); + await controller.open('course-12345678'); + const before = controller.view.snapshot; + + runtime.planChangeImpl = async () => { throw new Error('submit unavailable'); }; + await expect(controller.requestPlanChange('面向零基础读者')) + .rejects.toThrow('submit unavailable'); + expect(controller.view.planChange.status).toBe('failed'); + expect(controller.view.snapshot).toBe(before); + + runtime.planChangeImpl = async () => {}; + await controller.requestPlanChange('面向零基础读者'); + expect(runtime.planChangeCalls).toHaveLength(2); + expect(controller.view.planChange.status).toBe('waiting'); + + // A real session-idle event without a new ready revision is a recoverable + // failure, not permission to discard the previous outline. + runtime.handlers?.onArtifactChanged(); + await vi.waitFor(() => { expect(controller.view.planChange.status).toBe('failed'); }); + expect(controller.view.snapshot?.plan.revision).toBe('plan-v1'); + expect(controller.view.stage).toBe('working'); + }); + + it('keeps the last valid outline when the completed edit leaves no snapshot', async () => { + const runtime = new FakeRuntime(); + const original = readySnapshot('course-12345678'); + runtime.snapshots.set('course-12345678', original); + runtime.resumeImpl = async (courseId) => binding(courseId); + const controller = makeController(runtime); + await controller.open('course-12345678'); + + runtime.planChangeImpl = async () => {}; + await controller.requestPlanChange('把风险分析移到最前面'); + runtime.loadSnapshotImpl = async () => ({ status: 'missing' }); + runtime.handlers?.onArtifactChanged(); + await vi.waitFor(() => { expect(controller.view.planChange.status).toBe('failed'); }); + + expect(controller.view.snapshot).toBe(original); + expect(controller.view.stage).toBe('working'); + expect(controller.view.issues).toEqual([]); + }); + + it('rejects an empty outline instruction before calling the runtime', async () => { + const runtime = new FakeRuntime(); + runtime.snapshots.set('course-12345678', readySnapshot('course-12345678')); + runtime.resumeImpl = async (courseId) => binding(courseId); + const controller = makeController(runtime); + await controller.open('course-12345678'); + + await expect(controller.requestPlanChange(' ')).rejects.toThrow('empty'); + expect(runtime.planChangeCalls).toHaveLength(0); + }); + it('keeps a newer question that arrived while an answer was in flight', async () => { const runtime = new FakeRuntime(); const controller = makeController(runtime); @@ -576,6 +810,26 @@ describe('StudyProductController concurrency guards', () => { expect(controller.view.snapshot?.generation.status).not.toBe('generating'); }); + it('confirms one plan revision only once when generation is double-clicked', async () => { + const runtime = new FakeRuntime(); + runtime.snapshots.set('course-12345678', readySnapshot('course-12345678')); + runtime.resumeImpl = async (courseId) => binding(courseId); + const controller = makeController(runtime); + await controller.open('course-12345678'); + + const gate = deferred(); + runtime.generateDelay = gate.promise; + const first = controller.generate(); + const duplicate = controller.generate(); + await duplicate; + expect(runtime.generateCalls).toEqual([{ + courseId: 'course-12345678', + planRevision: 'plan-v1', + }]); + gate.resolve(undefined); + await first; + }); + it('refreshes the authoritative snapshot after upgrade without a watcher event', async () => { const runtime = new FakeRuntime(); const controller = makeController(runtime); From 3b16eaec6b374bb03797f1c6afaf75ac9d6335a3 Mon Sep 17 00:00:00 2001 From: microseyuyu Date: Sat, 18 Jul 2026 21:45:00 +0800 Subject: [PATCH 06/12] feat(study): surface outline revision feedback --- apps/kimi-web/src/i18n/locales/en/study.ts | 7 +- apps/kimi-web/src/i18n/locales/zh/study.ts | 7 +- .../study/components/product/StudyOutline.vue | 102 +++++++++--------- .../src/study/composables/useStudyProduct.ts | 5 +- .../src/study/product/studyScreenModel.ts | 7 +- apps/kimi-web/test/study-screen-model.test.ts | 11 ++ 6 files changed, 82 insertions(+), 57 deletions(-) diff --git a/apps/kimi-web/src/i18n/locales/en/study.ts b/apps/kimi-web/src/i18n/locales/en/study.ts index 86f739d1..a5b6d0c9 100644 --- a/apps/kimi-web/src/i18n/locales/en/study.ts +++ b/apps/kimi-web/src/i18n/locales/en/study.ts @@ -71,10 +71,13 @@ export default { outlineDesigning: 'Kimi is designing the outline from your material and goal…', outlineCounts: '{chapters} chapters · {pages} pages · {quizzes} quizzes', outlineReviseTitle: 'Ask for a change', - outlineRevisePlaceholder: 'e.g. “Add a chapter on boundary cases”…', + outlineRevisePlaceholder: 'Describe the outline change in one sentence…', + outlineReviseExample: 'For example: assume no prior knowledge; lead with examples; reduce to 6 lessons; move risk analysis first.', outlineReviseAction: 'Update outline', outlineReviseSent: 'Change requested — a new outline revision will appear here once it passes review.', - outlineReviseError: 'The outline could not be updated right now. Try again in a moment.', + outlineReviseWaiting: 'Kimi is revising and checking the outline. The current version stays in place until the new one is ready.', + outlineReviseSuccess: 'The new outline passed its checks and is ready for your confirmation.', + outlineReviseError: 'This change did not finish. The previous outline is intact, and you can retry now.', planRevision: 'Outline revision {revision}', generate: 'Generate course', generating: 'Generating lessons…', diff --git a/apps/kimi-web/src/i18n/locales/zh/study.ts b/apps/kimi-web/src/i18n/locales/zh/study.ts index 6c362f56..5b6ea493 100644 --- a/apps/kimi-web/src/i18n/locales/zh/study.ts +++ b/apps/kimi-web/src/i18n/locales/zh/study.ts @@ -71,10 +71,13 @@ export default { outlineDesigning: 'Kimi 正在根据材料和目标设计大纲…', outlineCounts: '{chapters} 章 · {pages} 页 · {quizzes} 个测验', outlineReviseTitle: '提出修改', - outlineRevisePlaceholder: '例如:“加一章关于边界情况的讲解”…', + outlineRevisePlaceholder: '用一句话说明你想怎样调整大纲…', + outlineReviseExample: '例如:面向零基础读者;先讲案例再讲原理;缩减为 6 节;把风险分析移到最前面。', outlineReviseAction: '更新大纲', outlineReviseSent: '已提交修改——通过检查后,新的大纲版本会出现在这里。', - outlineReviseError: '暂时无法更新大纲,请稍后再试。', + outlineReviseWaiting: 'Kimi 正在按你的要求修改并检查大纲,当前版本会保留到新版本就绪。', + outlineReviseSuccess: '新大纲已通过检查并更新。请确认后再开始生成课程。', + outlineReviseError: '这次修改没有完成,原大纲已保留。你可以直接重试。', planRevision: '大纲版本 {revision}', generate: '生成课程', generating: '正在生成课程…', diff --git a/apps/kimi-web/src/study/components/product/StudyOutline.vue b/apps/kimi-web/src/study/components/product/StudyOutline.vue index 2f265bcf..d9d0f04b 100644 --- a/apps/kimi-web/src/study/components/product/StudyOutline.vue +++ b/apps/kimi-web/src/study/components/product/StudyOutline.vue @@ -9,9 +9,11 @@ import { computed, inject, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import Button from '../../../components/ui/Button.vue'; +import Banner from '../../../components/ui/Banner.vue'; import Card from '../../../components/ui/Card.vue'; import Icon from '../../../components/ui/Icon.vue'; import Badge from '../../../components/ui/Badge.vue'; +import Textarea from '../../../components/ui/Textarea.vue'; import { parseQuickPlanOutline, QUICK_PLAN_PATH, @@ -54,14 +56,29 @@ const revisionLabel = computed(() => { }); // --- Outline items from the workspace plan artifact (quick survey plans). --- -const outline = ref(undefined); +const loadedOutline = ref<{ + readonly revision: string; + readonly outline: CourseOutline; +} | undefined>(undefined); +const outline = computed(() => { + const loaded = loadedOutline.value; + if (loaded === undefined || loaded.revision !== props.snapshot.plan.revision) return undefined; + return loaded.outline; +}); +let outlineLoadEpoch = 0; async function loadOutlineItems(): Promise { - outline.value = undefined; - if (!planReady.value) return; + const epoch = ++outlineLoadEpoch; + const revision = props.snapshot.plan.revision; + if (!planReady.value || revision === undefined) { + loadedOutline.value = undefined; + return; + } const loaded = await product.loadCourseText(QUICK_PLAN_PATH); + if (epoch !== outlineLoadEpoch || props.snapshot.plan.revision !== revision) return; if (loaded.status !== 'ready') return; - outline.value = parseQuickPlanOutline(loaded.content); + const parsed = parseQuickPlanOutline(loaded.content); + if (parsed !== undefined) loadedOutline.value = { revision, outline: parsed }; } onMounted(() => { void loadOutlineItems(); }); @@ -69,23 +86,22 @@ watch(() => props.snapshot.plan.revision, () => { void loadOutlineItems(); }); // --- Revisioned outline edits: every request targets the visible revision. --- const reviseText = ref(''); -const reviseSent = ref(false); -const reviseError = ref(false); -const revising = ref(false); +const planChange = computed(() => product.view.value.planChange); +const revising = computed(() => + planChange.value.status === 'submitting' || planChange.value.status === 'waiting'); + +watch(() => planChange.value.status, (status) => { + if (status === 'succeeded') reviseText.value = ''; +}); async function submitRevision(): Promise { const text = reviseText.value.trim(); if (text.length === 0 || revising.value) return; - revising.value = true; - reviseError.value = false; try { await product.requestPlanChange(text); - reviseText.value = ''; - reviseSent.value = true; } catch { - reviseError.value = true; - } finally { - revising.value = false; + // The controller preserves the previous outline and exposes a retryable + // failed state. Keep the learner's instruction in the textarea. } } @@ -150,24 +166,32 @@ async function submitRevision(): Promise { {{ t('study.product.outlineReviseTitle') }}
-