mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat(web): session list, chat pane, and daemon client updates
This commit is contained in:
parent
a660aff5a5
commit
affc69a0d3
24 changed files with 2676 additions and 138 deletions
1907
apps/kimi-web/dev/__md-stub.mjs
Normal file
1907
apps/kimi-web/dev/__md-stub.mjs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1045,6 +1045,40 @@ const server = http.createServer((req, res) => {
|
|||
return res.end(ok(s));
|
||||
}
|
||||
|
||||
// ---- sessions/{id}:compact ----
|
||||
// Mirrors the real daemon: 200 {} immediately, then raw agent-core
|
||||
// compaction.* frames over WS. After ~1.2s the stored history is rewritten
|
||||
// to [summary message with origin compaction_summary] + recent suffix, so
|
||||
// a later snapshot reload shows the compacted view like the real server.
|
||||
if (seg[0] === 'sessions' && seg.length === 2 && method === 'POST' && seg[1].endsWith(':compact')) {
|
||||
const realSid = seg[1].slice(0, -':compact'.length);
|
||||
const session = sessions.find((s) => s.id === realSid);
|
||||
if (!session) return res.end(fail(40401, `session ${realSid} does not exist`));
|
||||
const msgs = messages[realSid] || [];
|
||||
if (msgs.length < 2) return res.end(fail(40910, 'No prefix that can be compacted in current history.'));
|
||||
const b = json();
|
||||
const instruction = typeof b.instruction === 'string' && b.instruction.trim() ? b.instruction.trim() : undefined;
|
||||
|
||||
broadcast('compaction.started', realSid, { trigger: 'manual', instruction });
|
||||
setTimeout(() => {
|
||||
const all = messages[realSid] || [];
|
||||
const keep = all.slice(-2);
|
||||
const compactedCount = all.length - keep.length;
|
||||
const summaryText =
|
||||
'(stub) Summary of the earlier conversation: the user asked to make the API ' +
|
||||
'client timeout configurable; the assistant edited createClient, ran the tests ' +
|
||||
'(3 passed) and confirmed the change.' +
|
||||
(instruction ? `\n\nInstruction honoured: ${instruction}` : '');
|
||||
const summaryMsg = mkMsg(ulid('msg_'), realSid, 'assistant', [t(summaryText)], undefined);
|
||||
summaryMsg.metadata = { origin: { kind: 'compaction_summary' } };
|
||||
messages[realSid] = [summaryMsg, ...keep];
|
||||
broadcast('compaction.completed', realSid, {
|
||||
result: { summary: summaryText, compactedCount, tokensBefore: 90000, tokensAfter: 12000 },
|
||||
});
|
||||
}, 1200);
|
||||
return res.end(ok({}));
|
||||
}
|
||||
|
||||
// ---- sessions/{id} ----
|
||||
if (seg[0] === 'sessions' && sid && seg.length === 2) {
|
||||
const session = sessions.find((s) => s.id === sid);
|
||||
|
|
|
|||
254
apps/kimi-web/share-icons-preview.html
Normal file
254
apps/kimi-web/share-icons-preview.html
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Share Icon Options</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
h1 { font-size: 18px; font-weight: 500; color: #fff; margin-bottom: 8px; }
|
||||
.subtitle { font-size: 13px; color: #888; margin-bottom: 16px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
}
|
||||
.card {
|
||||
background: #252525;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
padding: 28px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: #555;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
.card.active {
|
||||
border-color: #4a9eff;
|
||||
background: #1e2d3f;
|
||||
}
|
||||
.icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #888;
|
||||
}
|
||||
.card:hover .icon-wrap { color: #e0e0e0; }
|
||||
.card.active .icon-wrap { color: #4a9eff; }
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.card.active .label { color: #4a9eff; }
|
||||
.name {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* TabBar context preview */
|
||||
.tabbar-preview {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
background: #252525;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tabbar-left {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.tabbar-tab {
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
color: #666;
|
||||
border-right: 1px solid #333;
|
||||
cursor: default;
|
||||
}
|
||||
.tabbar-tab.on {
|
||||
background: #1a1a1a;
|
||||
color: #4a9eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tabbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.tabbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
.tabbar-btn:hover { color: #e0e0e0; background: #333; }
|
||||
.tabbar-btn .btn-label {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.15s ease, max-width 0.2s ease;
|
||||
}
|
||||
.tabbar-btn:hover .btn-label {
|
||||
opacity: 1;
|
||||
max-width: 120px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>选择分享图标</h1>
|
||||
<p class="subtitle">点击卡片选中,下方会实时预览在 TabBar 中的效果</p>
|
||||
|
||||
<div class="grid" id="grid">
|
||||
<!-- A -->
|
||||
<div class="card" data-opt="A" onclick="select('A')">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="4" r="1.3" fill="currentColor" stroke="none"/>
|
||||
<circle cx="4" cy="8" r="1.3" fill="currentColor" stroke="none"/>
|
||||
<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/>
|
||||
<path d="M5.3 7.2l5.4-2.4"/>
|
||||
<path d="M5.3 8.8l5.4 2.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="label">A</span>
|
||||
<span class="name">iOS/Android Share</span>
|
||||
</div>
|
||||
|
||||
<!-- B -->
|
||||
<div class="card" data-opt="B" onclick="select('B')">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 11V3"/>
|
||||
<path d="M4 6l4-3 4 3"/>
|
||||
<path d="M2 13h12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="label">B</span>
|
||||
<span class="name">Arrow Up from Tray</span>
|
||||
</div>
|
||||
|
||||
<!-- C -->
|
||||
<div class="card" data-opt="C" onclick="select('C')">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 2h8v8"/>
|
||||
<path d="M14 2L2 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="label">C</span>
|
||||
<span class="name">Arrow Out of Box</span>
|
||||
</div>
|
||||
|
||||
<!-- D -->
|
||||
<div class="card" data-opt="D" onclick="select('D')">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 10c0-3.5 2.5-6 6-6"/>
|
||||
<path d="M6 4l3 3-3 3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="label">D</span>
|
||||
<span class="name">Curved Forward</span>
|
||||
</div>
|
||||
|
||||
<!-- E -->
|
||||
<div class="card" data-opt="E" onclick="select('E')">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 8l12-6-6 12-2-5-5-1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="label">E</span>
|
||||
<span class="name">Paper Plane</span>
|
||||
</div>
|
||||
|
||||
<!-- F -->
|
||||
<div class="card" data-opt="F" onclick="select('F')">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 3v8"/>
|
||||
<path d="M4 9l4 4 4-4"/>
|
||||
<path d="M2 15h12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="label">F</span>
|
||||
<span class="name">Export Arrow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">下方是 TabBar 中的实际预览效果</p>
|
||||
|
||||
<div class="tabbar-preview">
|
||||
<div class="tabbar-left">
|
||||
<div class="tabbar-tab on">chat</div>
|
||||
<div class="tabbar-tab">files</div>
|
||||
<div class="tabbar-tab">tasks <span style="color:#666;font-size:11px;">2</span></div>
|
||||
</div>
|
||||
<div class="tabbar-right">
|
||||
<button class="tabbar-btn" id="previewBtn">
|
||||
<span id="previewIcon"></span>
|
||||
<span class="btn-label">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const icons = {
|
||||
A: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4" r="1.3" fill="currentColor" stroke="none"/><circle cx="4" cy="8" r="1.3" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/><path d="M5.3 7.2l5.4-2.4"/><path d="M5.3 8.8l5.4 2.4"/></svg>`,
|
||||
B: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 11V3"/><path d="M4 6l4-3 4 3"/><path d="M2 13h12"/></svg>`,
|
||||
C: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h8v8"/><path d="M14 2L2 14"/></svg>`,
|
||||
D: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 10c0-3.5 2.5-6 6-6"/><path d="M6 4l3 3-3 3"/></svg>`,
|
||||
E: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8l12-6-6 12-2-5-5-1z"/></svg>`,
|
||||
F: `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v8"/><path d="M4 9l4 4 4-4"/><path d="M2 15h12"/></svg>`,
|
||||
};
|
||||
|
||||
function select(opt) {
|
||||
document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
|
||||
document.querySelector(`.card[data-opt="${opt}"]`).classList.add('active');
|
||||
document.getElementById('previewIcon').innerHTML = icons[opt];
|
||||
}
|
||||
|
||||
select('A');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -87,12 +87,24 @@ const sideWidth = computed(() => sessionColWidth.value);
|
|||
// ---------------------------------------------------------------------------
|
||||
const PREVIEW_WIDTH_KEY = 'kimi-web.file-preview-width';
|
||||
const PREVIEW_MIN = 320;
|
||||
const PREVIEW_MAX = 760;
|
||||
const PREVIEW_DEFAULT = typeof window !== 'undefined'
|
||||
? Math.max(PREVIEW_MIN, Math.min(PREVIEW_MAX, Math.round(window.innerWidth / 2)))
|
||||
: 460;
|
||||
|
||||
const previewWidth = ref(PREVIEW_DEFAULT);
|
||||
function previewAreaWidth(): number {
|
||||
if (typeof window === 'undefined') return PREVIEW_MIN * 2;
|
||||
return Math.max(0, window.innerWidth - sideWidth.value);
|
||||
}
|
||||
|
||||
function clampPreviewWidth(width: number): number {
|
||||
const max = Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN);
|
||||
return Math.min(max, Math.max(PREVIEW_MIN, Math.round(width)));
|
||||
}
|
||||
|
||||
function defaultPreviewWidth(): number {
|
||||
return clampPreviewWidth(previewAreaWidth() / 2);
|
||||
}
|
||||
|
||||
const previewDefaultWidth = computed(() => defaultPreviewWidth());
|
||||
const previewMaxWidth = computed(() => Math.max(PREVIEW_MIN, previewAreaWidth() - PREVIEW_MIN));
|
||||
const previewWidth = ref(previewDefaultWidth.value);
|
||||
const previewTarget = ref<FilePreviewRequest | null>(null);
|
||||
const previewFile = ref<FileData | null>(null);
|
||||
const previewLoading = ref(false);
|
||||
|
|
@ -158,6 +170,7 @@ function normalizePreviewPath(inputPath: string): { path: string } | { error: st
|
|||
|
||||
async function openFilePreview(target: FilePreviewRequest): Promise<void> {
|
||||
thinkingTarget.value = null; // shared right-side slot
|
||||
compactionTarget.value = null;
|
||||
const normalized = normalizePreviewPath(target.path);
|
||||
previewTarget.value = target;
|
||||
previewFile.value = null;
|
||||
|
|
@ -193,6 +206,7 @@ function mimeFromDataUrl(url: string): string | undefined {
|
|||
function openMediaPreview(media: ToolMedia): void {
|
||||
if (media.kind !== 'image') return;
|
||||
thinkingTarget.value = null;
|
||||
compactionTarget.value = null;
|
||||
previewRequestSeq++;
|
||||
previewTarget.value = null;
|
||||
previewError.value = null;
|
||||
|
|
@ -243,6 +257,7 @@ function openThinkingPanel(target: { turnId: string; blockIndex: number }): void
|
|||
return;
|
||||
}
|
||||
closeFilePreview();
|
||||
compactionTarget.value = null;
|
||||
thinkingTarget.value = target;
|
||||
}
|
||||
|
||||
|
|
@ -250,8 +265,41 @@ function closeThinkingPanel(): void {
|
|||
thinkingTarget.value = null;
|
||||
}
|
||||
|
||||
/** Either occupant of the shared right-side slot. */
|
||||
const sidePanelVisible = computed(() => previewVisible.value || thinkingVisible.value);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compaction summary panel — shares the right-side slot too. Opened from a
|
||||
// "context compacted" divider in the transcript; resolves the summary text
|
||||
// reactively from the divider turn.
|
||||
// ---------------------------------------------------------------------------
|
||||
const compactionTarget = ref<{ turnId: string } | null>(null);
|
||||
|
||||
const compactionPanelText = computed<string | null>(() => {
|
||||
const target = compactionTarget.value;
|
||||
if (!target) return null;
|
||||
const turn = client.turns.value.find((tn) => tn.id === target.turnId);
|
||||
return turn?.role === 'compaction' && turn.text ? turn.text : null;
|
||||
});
|
||||
|
||||
const compactionPanelVisible = computed(() => compactionPanelText.value !== null);
|
||||
|
||||
function openCompactionPanel(target: { turnId: string }): void {
|
||||
// Clicking the SAME divider again closes the drawer (toggle).
|
||||
if (compactionTarget.value?.turnId === target.turnId) {
|
||||
compactionTarget.value = null;
|
||||
return;
|
||||
}
|
||||
closeFilePreview();
|
||||
thinkingTarget.value = null;
|
||||
compactionTarget.value = target;
|
||||
}
|
||||
|
||||
function closeCompactionPanel(): void {
|
||||
compactionTarget.value = null;
|
||||
}
|
||||
|
||||
/** Any occupant of the shared right-side slot. */
|
||||
const sidePanelVisible = computed(
|
||||
() => previewVisible.value || thinkingVisible.value || compactionPanelVisible.value,
|
||||
);
|
||||
|
||||
/** True while the panel's resize handle is being dragged — the width
|
||||
transition is disabled so the panel follows the pointer 1:1. */
|
||||
|
|
@ -272,6 +320,7 @@ function revealPreviewFile(): void {
|
|||
watch(client.activeSessionId, () => {
|
||||
closeFilePreview();
|
||||
closeThinkingPanel();
|
||||
closeCompactionPanel();
|
||||
});
|
||||
|
||||
// Reference to ConversationPane so we can imperatively switch tabs
|
||||
|
|
@ -376,6 +425,12 @@ async function handleLoginSuccess(): Promise<void> {
|
|||
|
||||
// Handler for slash commands emitted by Composer (via ConversationPane)
|
||||
function handleCommand(cmd: string): void {
|
||||
// `/compact <text>` carries an optional free-text instruction steering what
|
||||
// the summary should focus on (TUI parity).
|
||||
if (cmd === '/compact' || cmd.startsWith('/compact ')) {
|
||||
client.compact(cmd.slice('/compact'.length).trim() || undefined);
|
||||
return;
|
||||
}
|
||||
switch (cmd) {
|
||||
case '/new':
|
||||
case '/clear':
|
||||
|
|
@ -384,9 +439,6 @@ function handleCommand(cmd: string): void {
|
|||
case '/sessions':
|
||||
showSessions.value = true;
|
||||
break;
|
||||
case '/compact':
|
||||
client.compact();
|
||||
break;
|
||||
case '/fork':
|
||||
void client.forkSession();
|
||||
break;
|
||||
|
|
@ -588,6 +640,7 @@ function handleCreateSessionInWorkspace(workspaceId: string): void {
|
|||
@open-file="openFilePreview($event)"
|
||||
@open-media="openMediaPreview($event)"
|
||||
@open-thinking="openThinkingPanel($event)"
|
||||
@open-compaction="openCompactionPanel($event)"
|
||||
/>
|
||||
|
||||
<!-- Multi-workspace selection placeholder -->
|
||||
|
|
@ -599,9 +652,9 @@ function handleCreateSessionInWorkspace(workspaceId: string): void {
|
|||
<ResizeHandle
|
||||
v-if="sidePanelVisible && !isMobile"
|
||||
:storage-key="PREVIEW_WIDTH_KEY"
|
||||
:default-width="PREVIEW_DEFAULT"
|
||||
:default-width="previewDefaultWidth"
|
||||
:min="PREVIEW_MIN"
|
||||
:max="PREVIEW_MAX"
|
||||
:max="previewMaxWidth"
|
||||
reverse
|
||||
:aria-label="t('layout.resizePreviewAria')"
|
||||
@update:width="previewWidth = $event"
|
||||
|
|
@ -623,6 +676,12 @@ function handleCreateSessionInWorkspace(workspaceId: string): void {
|
|||
:text="thinkingPanelText ?? ''"
|
||||
@close="closeThinkingPanel"
|
||||
/>
|
||||
<ThinkingPanel
|
||||
v-else-if="compactionPanelVisible"
|
||||
:text="compactionPanelText ?? ''"
|
||||
:subtitle="t('conversation.summaryTitle')"
|
||||
@close="closeCompactionPanel"
|
||||
/>
|
||||
<FilePreview
|
||||
v-else-if="previewVisible"
|
||||
:file="previewFile"
|
||||
|
|
|
|||
|
|
@ -798,16 +798,17 @@ export function createAgentProjector(): AgentProjector {
|
|||
// -----------------------------------------------------------------------
|
||||
case 'compaction.completed': {
|
||||
// Compaction replaced a batch of old messages with a summary on the
|
||||
// daemon side. The in-memory transcript is now stale, so signal a reload.
|
||||
// beforeSeq is patched to the real frame.seq by the client (the projector
|
||||
// does not receive the wire seq); the client routes historyCompacted to
|
||||
// onResync to refetch /messages.
|
||||
// daemon side. The visible transcript is NOT reloaded (the client keeps
|
||||
// the scrollback and the reducer appends a divider marker); the
|
||||
// historyCompacted signal still fires so seq bookkeeping and any
|
||||
// non-compaction consumers stay correct.
|
||||
const result = (p?.result ?? {}) as Record<string, unknown>;
|
||||
out.push({
|
||||
type: 'compactionCompleted',
|
||||
sessionId,
|
||||
tokensBefore: typeof result.tokensBefore === 'number' ? result.tokensBefore : undefined,
|
||||
tokensAfter: typeof result.tokensAfter === 'number' ? result.tokensAfter : undefined,
|
||||
summary: typeof result.summary === 'string' ? result.summary : undefined,
|
||||
});
|
||||
out.push({
|
||||
type: 'historyCompacted',
|
||||
|
|
|
|||
|
|
@ -175,6 +175,16 @@ interface WireDiffResult {
|
|||
diff: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* historyCompacted reasons caused by compaction itself. These do NOT trigger a
|
||||
* snapshot reload: the client keeps the visible scrollback and renders a
|
||||
* divider marker instead. Every other reason (delta_gap, history_rewrite, …)
|
||||
* still means "cached messages are stale" and goes through onResync.
|
||||
*/
|
||||
function isCompactionReason(reason: string): boolean {
|
||||
return reason === 'auto_compact' || reason === 'manual_compact';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DaemonKimiWebApi
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -433,8 +443,9 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
return { aborted: data.aborted, atSeq: data.at_seq };
|
||||
}
|
||||
|
||||
// POST /sessions/{id}:compact — request history compaction. Returns {}; the
|
||||
// compacted history arrives via the WS history_compacted → onResync reload.
|
||||
// POST /sessions/{id}:compact — request history compaction. Returns {};
|
||||
// progress and completion arrive via the WS compaction.* events (the
|
||||
// transcript itself is not reloaded — a divider marker is appended).
|
||||
async compactSession(sessionId: string, instruction?: string): Promise<void> {
|
||||
await this.http.post(
|
||||
`/sessions/${encodeURIComponent(sessionId)}:compact`,
|
||||
|
|
@ -962,8 +973,11 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
const seq = wireEventSeq(wireEvent);
|
||||
const appEvent = toAppEvent(wireEvent);
|
||||
|
||||
// Route history_compacted to onResync so client can reload messages
|
||||
if (appEvent.type === 'historyCompacted') {
|
||||
// Route history_compacted to onResync so the client reloads messages —
|
||||
// EXCEPT for compaction itself: the transcript keeps the scrollback and
|
||||
// the reducer appends a divider marker instead (reloading would replace
|
||||
// the visible conversation with the compacted model context).
|
||||
if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) {
|
||||
handlers.onResync(appEvent.sessionId, appEvent.beforeSeq);
|
||||
// Still dispatch the event to onEvent so the reducer can update lastSeqBySession
|
||||
}
|
||||
|
|
@ -980,10 +994,11 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
const { type, seq, session_id: sessionId, payload, offset } = frame;
|
||||
const appEvents = projector.project(type, payload, sessionId, { offset });
|
||||
for (const appEvent of appEvents) {
|
||||
// Auto-compaction: the projector can't see the wire seq, so it emits
|
||||
// historyCompacted with beforeSeq:0. Route it to onResync using the
|
||||
// real frame.seq to reload /messages, mirroring the protocol path.
|
||||
if (appEvent.type === 'historyCompacted') {
|
||||
// historyCompacted from the projector is either a compaction signal
|
||||
// (reason auto_compact — no reload, the divider marker handles it) or
|
||||
// a delta-gap recovery (reason delta_gap — a real resync, routed to
|
||||
// onResync with the real frame.seq, mirroring the protocol path).
|
||||
if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) {
|
||||
handlers.onResync(sessionId, seq);
|
||||
}
|
||||
handlers.onEvent(appEvent, { sessionId, seq });
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import type {
|
|||
AppQuestionRequest,
|
||||
AppSession,
|
||||
AppTask,
|
||||
CompactionMarkerMetadata,
|
||||
} from '../types';
|
||||
import { COMPACTION_MARKER_METADATA_KEY } from '../types';
|
||||
import { i18n } from '../../i18n';
|
||||
|
||||
const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage';
|
||||
|
|
@ -25,13 +27,12 @@ const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage';
|
|||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Live compaction progress for a session (TUI parity: "Compacting context…"
|
||||
while running, then a one-shot "complete (X → Y tokens)" note). */
|
||||
/** Live compaction progress for a session: present (status 'running') only
|
||||
while the daemon is compacting. Completion is recorded as a persistent
|
||||
divider marker message in the transcript, not as transient status. */
|
||||
export interface CompactionStatus {
|
||||
status: 'running' | 'completed';
|
||||
status: 'running';
|
||||
trigger: 'manual' | 'auto';
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
}
|
||||
|
||||
export interface KimiClientState {
|
||||
|
|
@ -224,16 +225,41 @@ export function reduceAppEvent(
|
|||
}
|
||||
|
||||
case 'compactionCompleted': {
|
||||
const prev = next.compactionBySession[event.sessionId];
|
||||
next.compactionBySession = {
|
||||
...next.compactionBySession,
|
||||
[event.sessionId]: {
|
||||
status: 'completed',
|
||||
trigger: prev?.trigger ?? 'auto',
|
||||
tokensBefore: event.tokensBefore,
|
||||
tokensAfter: event.tokensAfter,
|
||||
},
|
||||
};
|
||||
const sid = event.sessionId;
|
||||
const prev = next.compactionBySession[sid];
|
||||
const { [sid]: _doneEntry, ...rest } = next.compactionBySession;
|
||||
next.compactionBySession = rest;
|
||||
|
||||
// Append a persistent "context compacted" divider to the loaded
|
||||
// transcript (TUI parity: the scrollback is kept untouched; only a
|
||||
// one-line marker records that compaction happened). The marker id is
|
||||
// derived from the wire seq so an event replay after reconnect can't
|
||||
// duplicate it.
|
||||
if (Object.prototype.hasOwnProperty.call(next.messagesBySession, sid)) {
|
||||
const msgs = next.messagesBySession[sid] ?? [];
|
||||
const markerId = `compaction_${sid}_${meta.seq}`;
|
||||
if (!msgs.some((m) => m.id === markerId)) {
|
||||
const marker: CompactionMarkerMetadata = {
|
||||
trigger: prev?.trigger ?? 'auto',
|
||||
tokensBefore: event.tokensBefore,
|
||||
tokensAfter: event.tokensAfter,
|
||||
};
|
||||
next.messagesBySession[sid] = [
|
||||
...msgs,
|
||||
{
|
||||
id: markerId,
|
||||
sessionId: sid,
|
||||
role: 'assistant',
|
||||
content: event.summary ? [{ type: 'text', text: event.summary }] : [],
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
origin: { kind: 'compaction_summary' },
|
||||
[COMPACTION_MARKER_METADATA_KEY]: marker,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -142,6 +142,21 @@ export interface AppMessage {
|
|||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata key of the client-side compaction marker message appended on
|
||||
* compactionCompleted. The transcript keeps all prior messages (TUI parity);
|
||||
* this marker renders as a "context compacted" divider. Snapshot-loaded
|
||||
* summary messages (origin kind 'compaction_summary') render the same way
|
||||
* but carry no token stats.
|
||||
*/
|
||||
export const COMPACTION_MARKER_METADATA_KEY = 'kimiWeb.compaction';
|
||||
|
||||
export interface CompactionMarkerMetadata {
|
||||
trigger: 'manual' | 'auto';
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -290,7 +305,7 @@ export type AppEvent =
|
|||
| { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string }
|
||||
| { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string }
|
||||
| { type: 'compactionStarted'; sessionId: string; trigger: 'manual' | 'auto'; instruction?: string }
|
||||
| { type: 'compactionCompleted'; sessionId: string; tokensBefore?: number; tokensAfter?: number }
|
||||
| { type: 'compactionCompleted'; sessionId: string; tokensBefore?: number; tokensAfter?: number; summary?: string }
|
||||
| { type: 'compactionCancelled'; sessionId: string }
|
||||
| { type: 'messageCreated'; message: AppMessage }
|
||||
| { type: 'messageUpdated'; sessionId: string; messageId: string; content: AppMessageContent[]; status: 'pending' | 'completed' | 'error' }
|
||||
|
|
|
|||
66
apps/kimi-web/src/components/ActivityNotice.vue
Normal file
66
apps/kimi-web/src/components/ActivityNotice.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<!-- apps/kimi-web/src/components/ActivityNotice.vue -->
|
||||
<!-- Generic in-transcript "working on X" notice: the moon-phase spinner plus a
|
||||
body-sized label. Used for long-running session activities that are not a
|
||||
chat turn (e.g. "Compacting context…"). Renders inline at the end of the
|
||||
transcript in both the bubble and line layouts. -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
}>();
|
||||
|
||||
const MOON_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
|
||||
const MOON_INTERVAL_MS = 120;
|
||||
|
||||
const moonFrame = ref(0);
|
||||
let moonInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
moonInterval = setInterval(() => {
|
||||
moonFrame.value = (moonFrame.value + 1) % MOON_FRAMES.length;
|
||||
}, MOON_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (moonInterval) {
|
||||
clearInterval(moonInterval);
|
||||
moonInterval = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="activity-notice" role="status">
|
||||
<span class="an-moon" aria-hidden="true">{{ MOON_FRAMES[moonFrame] }}</span>
|
||||
<span class="an-label">{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Same size as assistant body text (.a-msg .msg / Markdown) so the notice
|
||||
reads as part of the conversation, not as chrome. */
|
||||
.activity-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
margin: 6px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink);
|
||||
}
|
||||
.an-moon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Mobile font bump (+2px), matching ChatPane's body text. */
|
||||
@media (max-width: 640px) {
|
||||
.activity-notice,
|
||||
.an-moon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,6 +10,7 @@ import ToolCall from './ToolCall.vue';
|
|||
import ApprovalCard from './ApprovalCard.vue';
|
||||
import Markdown from './Markdown.vue';
|
||||
import ThinkingBlock from './ThinkingBlock.vue';
|
||||
import ActivityNotice from './ActivityNotice.vue';
|
||||
|
||||
|
||||
const MOON_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
|
||||
|
|
@ -74,11 +75,11 @@ const props = withDefaults(
|
|||
*/
|
||||
sessionLoading?: boolean;
|
||||
/**
|
||||
* Live compaction state of the session: a "compacting…" banner while the
|
||||
* daemon rewrites history, then a transient "compacted (X → Y tokens)" note
|
||||
* (auto-dismissed by the composable).
|
||||
* Live compaction state of the session: non-null while the daemon rewrites
|
||||
* history, rendered as a body-sized "Compacting context…" activity notice.
|
||||
* Completion is a persistent divider turn (role 'compaction') in `turns`.
|
||||
*/
|
||||
compaction?: { status: 'running' | 'completed'; tokensBefore?: number; tokensAfter?: number } | null;
|
||||
compaction?: { status: 'running' } | null;
|
||||
/**
|
||||
* @deprecated No longer used — Composer is rendered by ConversationPane.
|
||||
*/
|
||||
|
|
@ -106,17 +107,32 @@ const emit = defineEmits<{
|
|||
copyConversationCopied: [];
|
||||
/** Show a thinking block's full text in the right-side panel. */
|
||||
openThinking: [target: { turnId: string; blockIndex: number }];
|
||||
/** Show a compaction divider's summary text in the right-side panel. */
|
||||
openCompaction: [target: { turnId: string }];
|
||||
}>();
|
||||
|
||||
const compactionLabel = computed<string>(() => {
|
||||
const c = props.compaction;
|
||||
if (!c) return '';
|
||||
if (c.status === 'running') return t('conversation.compacting');
|
||||
if (typeof c.tokensBefore === 'number' && typeof c.tokensAfter === 'number') {
|
||||
return t('conversation.compacted', { before: c.tokensBefore, after: c.tokensAfter });
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Divider label: "Context compacted"/"auto-compacted" + optional token stats. */
|
||||
function compactionDividerLabel(turn: ChatTurn): string {
|
||||
const c = turn.compaction;
|
||||
const base =
|
||||
c?.trigger === 'auto' ? t('conversation.compactedAuto') : t('conversation.compactedPlain');
|
||||
if (typeof c?.tokensBefore === 'number' && typeof c?.tokensAfter === 'number') {
|
||||
return (
|
||||
base +
|
||||
t('conversation.compactedTokens', {
|
||||
before: formatTokens(c.tokensBefore),
|
||||
after: formatTokens(c.tokensAfter),
|
||||
})
|
||||
);
|
||||
}
|
||||
return t('conversation.compactedPlain');
|
||||
});
|
||||
return base;
|
||||
}
|
||||
|
||||
// Per-turn copy button state (keyed by turn id)
|
||||
const copiedTurn = ref<string | null>(null);
|
||||
|
|
@ -160,6 +176,7 @@ function copyConversation(): void {
|
|||
if (props.turns.length === 0) return;
|
||||
const lines: string[] = [];
|
||||
for (const turn of props.turns) {
|
||||
if (turn.role === 'compaction') continue; // dividers don't copy
|
||||
const roleLabel = turn.role === 'user' ? 'User' : 'Assistant';
|
||||
const content = turnToMarkdown(turn);
|
||||
if (content.trim()) {
|
||||
|
|
@ -265,6 +282,23 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
|
|||
<div class="u-text">{{ turn.text }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Compaction divider — prior turns stay untouched; summary opens in
|
||||
the right-side panel on click. -->
|
||||
<div v-else-if="turn.role === 'compaction'" class="compact-divider" role="separator">
|
||||
<span class="cd-line" aria-hidden="true" />
|
||||
<button
|
||||
v-if="turn.text"
|
||||
type="button"
|
||||
class="cd-label cd-btn"
|
||||
@click="emit('openCompaction', { turnId: turn.id })"
|
||||
>
|
||||
<span>{{ compactionDividerLabel(turn) }}</span>
|
||||
<span class="cd-view">{{ t('conversation.viewSummary') }}</span>
|
||||
</button>
|
||||
<span v-else class="cd-label">{{ compactionDividerLabel(turn) }}</span>
|
||||
<span class="cd-line" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- Assistant turn → left-aligned, no name/role label. -->
|
||||
<div v-else class="a-msg">
|
||||
<template v-for="(blk, bi) in turnBlocks(turn)" :key="bi">
|
||||
|
|
@ -299,11 +333,8 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
|
|||
@decide="(response) => emit('approvalDecide', a.approvalId, response)"
|
||||
/>
|
||||
|
||||
<!-- Compaction banner — running ("compacting…") or transient done note -->
|
||||
<div v-if="compaction" class="compaction-note" :class="compaction.status">
|
||||
<span v-if="compaction.status === 'running'" class="dot-pulse" aria-hidden="true" />
|
||||
<span>{{ compactionLabel }}</span>
|
||||
</div>
|
||||
<!-- Compaction in progress — body-sized moon activity notice -->
|
||||
<ActivityNotice v-if="compaction" :label="t('conversation.compacting')" />
|
||||
|
||||
<!-- Sending placeholder — moon spinner while the request is in flight -->
|
||||
<div v-if="sending" class="sending-placeholder">
|
||||
|
|
@ -323,7 +354,23 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
|
|||
<div v-else-if="turns.length === 0 && (!approvals || approvals.length === 0)" class="chat-empty" />
|
||||
|
||||
<template v-for="(turn, ti) in turns" :key="turn.id">
|
||||
<div class="ln" :class="turn.role === 'user' ? 'userline' : 'ai'">
|
||||
<!-- Compaction divider — full-width separator, no gutter number. -->
|
||||
<div v-if="turn.role === 'compaction'" class="compact-divider" role="separator">
|
||||
<span class="cd-line" aria-hidden="true" />
|
||||
<button
|
||||
v-if="turn.text"
|
||||
type="button"
|
||||
class="cd-label cd-btn"
|
||||
@click="emit('openCompaction', { turnId: turn.id })"
|
||||
>
|
||||
<span>{{ compactionDividerLabel(turn) }}</span>
|
||||
<span class="cd-view">{{ t('conversation.viewSummary') }}</span>
|
||||
</button>
|
||||
<span v-else class="cd-label">{{ compactionDividerLabel(turn) }}</span>
|
||||
<span class="cd-line" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div v-else class="ln" :class="turn.role === 'user' ? 'userline' : 'ai'">
|
||||
<!-- Line-number gutter -->
|
||||
<span class="no">{{ turn.no }}</span>
|
||||
|
||||
|
|
@ -377,11 +424,8 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
|
|||
@decide="(response) => emit('approvalDecide', a.approvalId, response)"
|
||||
/>
|
||||
|
||||
<!-- Compaction banner — running ("compacting…") or transient done note -->
|
||||
<div v-if="compaction" class="compaction-note" :class="compaction.status">
|
||||
<span v-if="compaction.status === 'running'" class="dot-pulse" aria-hidden="true" />
|
||||
<span>{{ compactionLabel }}</span>
|
||||
</div>
|
||||
<!-- Compaction in progress — body-sized moon activity notice -->
|
||||
<ActivityNotice v-if="compaction" :label="t('conversation.compacting')" />
|
||||
|
||||
<!-- Sending placeholder — moon spinner while the request is in flight -->
|
||||
<div v-if="sending" class="ln sending-line">
|
||||
|
|
@ -525,18 +569,43 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Compaction banner — "compacting…" while running, transient done note after. */
|
||||
.compaction-note {
|
||||
/* Compaction divider — a full-width separator marking where the daemon
|
||||
compacted the context. Prior turns above it are untouched; clicking the
|
||||
label opens the summary in the right-side panel. */
|
||||
.compact-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
margin: 6px 0;
|
||||
font-size: 12.5px;
|
||||
font-family: var(--mono);
|
||||
color: var(--faint);
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.compaction-note.completed { color: var(--ok); }
|
||||
.cd-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.cd-label {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 80%;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cd-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.cd-view { color: var(--blue); }
|
||||
.cd-btn:hover .cd-view { text-decoration: underline; }
|
||||
|
||||
/* Assistant message → left-aligned plain column, no role label */
|
||||
.a-msg {
|
||||
|
|
@ -679,5 +748,9 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
|
|||
.chat-loading-text {
|
||||
font-size: 15px;
|
||||
}
|
||||
.cd-label,
|
||||
.cd-btn {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -367,14 +367,15 @@ function handleSubmit(): void {
|
|||
|
||||
if (!trimmed && readyAttachments.length === 0) return;
|
||||
|
||||
// If it's a slash command (no space → treat as command trigger)
|
||||
// If it's a slash command (no space → treat as command trigger).
|
||||
// /compact also accepts a free-text instruction after the command, so it
|
||||
// stays a command instead of falling through as a chat message.
|
||||
if (trimmed) {
|
||||
const parsed = parseSlash(trimmed);
|
||||
if (parsed && !parsed.arg) {
|
||||
// pure command with no extra text
|
||||
if (parsed && (!parsed.arg || parsed.cmd === '/compact')) {
|
||||
text.value = '';
|
||||
slashOpen.value = false;
|
||||
emit('command', parsed.cmd);
|
||||
emit('command', parsed.arg ? `${parsed.cmd} ${parsed.arg}` : parsed.cmd);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ const props = defineProps<{
|
|||
modern?: boolean;
|
||||
/** True while switching sessions and the turns array is not yet loaded. */
|
||||
sessionLoading?: boolean;
|
||||
/** Live compaction state of the active session (running banner / done note). */
|
||||
compaction?: { status: 'running' | 'completed'; tokensBefore?: number; tokensAfter?: number } | null;
|
||||
/** Live compaction state of the active session (non-null while running). */
|
||||
compaction?: { status: 'running' } | null;
|
||||
/** Available models for the quick-switch dropdown in the composer toolbar. */
|
||||
models?: AppModel[];
|
||||
/** Workspace name shown in the empty-session hint above the centred composer. */
|
||||
|
|
@ -82,6 +82,7 @@ const emit = defineEmits<{
|
|||
openFile: [target: FilePreviewRequest];
|
||||
openMedia: [media: ToolMedia];
|
||||
openThinking: [target: { turnId: string; blockIndex: number }];
|
||||
openCompaction: [target: { turnId: string }];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -633,6 +634,7 @@ onUnmounted(() => {
|
|||
@open-media="emit('openMedia', $event)"
|
||||
@copy-conversation-copied="handleCopyConversationCopied"
|
||||
@open-thinking="emit('openThinking', $event)"
|
||||
@open-compaction="emit('openCompaction', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -248,14 +248,16 @@ watch(
|
|||
<!-- Name -->
|
||||
<span class="ft-name">{{ node.entry.name }}</span>
|
||||
|
||||
<!-- Git status badge -->
|
||||
<!-- Git status badge: prefer the entry's own gitStatus (available
|
||||
immediately from listDirectory with includeGitStatus=true) and
|
||||
fall back to the per-session changesByPath map. -->
|
||||
<span
|
||||
v-if="changesByPath[node.entry.path]"
|
||||
v-if="node.entry.gitStatus ?? changesByPath[node.entry.path]"
|
||||
class="ft-badge"
|
||||
:class="badgeKind(changesByPath[node.entry.path]!)"
|
||||
:title="changesByPath[node.entry.path]"
|
||||
:class="badgeKind(node.entry.gitStatus ?? changesByPath[node.entry.path]!)"
|
||||
:title="node.entry.gitStatus ?? changesByPath[node.entry.path]"
|
||||
>
|
||||
{{ badgeGlyph(changesByPath[node.entry.path]!) }}
|
||||
{{ badgeGlyph(node.entry.gitStatus ?? changesByPath[node.entry.path]!) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import { useI18n } from 'vue-i18n';
|
|||
|
||||
const props = defineProps<{
|
||||
text: string;
|
||||
/** Header label override — defaults to the thinking panel title. Lets the
|
||||
panel double as the compaction-summary viewer. */
|
||||
subtitle?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -37,7 +40,7 @@ watch(
|
|||
<div class="tp">
|
||||
<div class="tp-header">
|
||||
<span class="tp-title">{{ t('common.preview') }}</span>
|
||||
<span class="tp-sub">{{ t('thinking.panelTitle') }}</span>
|
||||
<span class="tp-sub">{{ subtitle ?? t('thinking.panelTitle') }}</span>
|
||||
<button type="button" class="tp-close" :title="t('thinking.close')" @click="emit('close')">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
// Fallback: if promptId is undefined on both the pending group and the
|
||||
// incoming message they are NOT merged (one turn per message, old behaviour).
|
||||
|
||||
import type { AppMessage, AppApprovalRequest } from '../api/types';
|
||||
import type { AppMessage, AppApprovalRequest, CompactionMarkerMetadata } from '../api/types';
|
||||
import { COMPACTION_MARKER_METADATA_KEY } from '../api/types';
|
||||
import type { ApprovalBlock, ChatTurn, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types';
|
||||
|
||||
const READ_MEDIA_TOOL_RE = /^read[_-]?media(?:file)?$/i;
|
||||
|
|
@ -285,6 +286,17 @@ function isDisplayableUserMessage(msg: AppMessage): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A compaction summary message — either the client-side marker appended on
|
||||
* compactionCompleted, or the daemon's synthetic ASSISTANT message that
|
||||
* replaces the compacted prefix in a reloaded snapshot. Both render as a
|
||||
* "context compacted" divider; the summary text opens in the side panel.
|
||||
*/
|
||||
function isCompactionSummaryMessage(msg: AppMessage): boolean {
|
||||
const origin = msg.metadata?.['origin'] as { kind?: string } | undefined;
|
||||
return origin?.kind === 'compaction_summary';
|
||||
}
|
||||
|
||||
export function messagesToTurns(
|
||||
messages: AppMessage[],
|
||||
approvals: AppApprovalRequest[],
|
||||
|
|
@ -404,6 +416,30 @@ export function messagesToTurns(
|
|||
for (const msg of messages) {
|
||||
if (msg.role === 'system') continue;
|
||||
|
||||
// Compaction summaries become a divider turn — never a chat bubble. The
|
||||
// snapshot variant carries no token stats (marker metadata is client-side).
|
||||
if (isCompactionSummaryMessage(msg)) {
|
||||
flushGroup();
|
||||
const marker = msg.metadata?.[COMPACTION_MARKER_METADATA_KEY] as
|
||||
| CompactionMarkerMetadata
|
||||
| undefined;
|
||||
turns.push({
|
||||
id: msg.id,
|
||||
role: 'compaction',
|
||||
no, // not displayed — dividers have no gutter number
|
||||
text: msg.content
|
||||
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('\n'),
|
||||
compaction: {
|
||||
trigger: marker?.trigger,
|
||||
tokensBefore: marker?.tokensBefore,
|
||||
tokensAfter: marker?.tokensAfter,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// User messages flush the pending group and start a new user turn
|
||||
if (msg.role === 'user') {
|
||||
flushGroup();
|
||||
|
|
|
|||
|
|
@ -480,27 +480,6 @@ function applyEvent(event: ReturnType<typeof toAppEvent>, sessionId: string, seq
|
|||
rawState.warnings = next.warnings;
|
||||
}
|
||||
|
||||
// Auto-dismiss the "compaction complete (X → Y tokens)" note a few seconds
|
||||
// after it appears. One timer per session; restarted on every completion.
|
||||
const compactionClearTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const COMPACTION_NOTE_MS = 5000;
|
||||
|
||||
function scheduleCompactionNoteDismiss(sessionId: string): void {
|
||||
const existing = compactionClearTimers.get(sessionId);
|
||||
if (existing !== undefined) clearTimeout(existing);
|
||||
compactionClearTimers.set(
|
||||
sessionId,
|
||||
setTimeout(() => {
|
||||
compactionClearTimers.delete(sessionId);
|
||||
const entry = rawState.compactionBySession[sessionId];
|
||||
if (entry?.status === 'completed') {
|
||||
const { [sessionId]: _gone, ...rest } = rawState.compactionBySession;
|
||||
rawState.compactionBySession = rest;
|
||||
}
|
||||
}, COMPACTION_NOTE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WS subscription (lazy, only when a session is selected)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -517,17 +496,11 @@ function connectEventsIfNeeded(): void {
|
|||
eventConn = api.connectEvents({
|
||||
onEvent(appEvent, meta) {
|
||||
// meta carries wire-level seq/sessionId so the reducer can advance
|
||||
// lastSeqBySession[sessionId] = seq. historyCompacted reload is owned
|
||||
// by onResync (the client routes it there); here we only let the reducer
|
||||
// run so lastSeqBySession stays current.
|
||||
// lastSeqBySession[sessionId] = seq. Compaction completion appends a
|
||||
// persistent divider marker in the reducer (TUI parity: the scrollback
|
||||
// is kept, only a marker line records the compaction).
|
||||
applyEvent(appEvent, meta.sessionId, meta.seq);
|
||||
|
||||
// Compaction completion note self-dismisses (TUI keeps a transcript
|
||||
// line; the web shows a transient banner instead).
|
||||
if (appEvent.type === 'compactionCompleted') {
|
||||
scheduleCompactionNoteDismiss(appEvent.sessionId);
|
||||
}
|
||||
|
||||
// Turn-end cleanup for the session the event belongs to — including
|
||||
// sessions running in the background (see onSessionIdle).
|
||||
if (
|
||||
|
|
@ -943,7 +916,7 @@ const todos = computed<TodoView[]>(() => {
|
|||
return latestTodos(rawState.messagesBySession[sid] ?? []);
|
||||
});
|
||||
|
||||
/** Live compaction state of the active session (running banner / done note). */
|
||||
/** Live compaction state of the active session (present only while running). */
|
||||
const compaction = computed<CompactionStatus | null>(() => {
|
||||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return null;
|
||||
|
|
@ -1735,8 +1708,6 @@ async function selectSession(
|
|||
if (rawState.activeWorkspaceId !== wid) selectWorkspace(wid);
|
||||
}
|
||||
|
||||
refreshSessionSidecars(sessionId);
|
||||
|
||||
if (!messagesLoaded) {
|
||||
// First open: full snapshot → seed → subscribe(asOfSeq).
|
||||
const result = await syncSessionFromSnapshot(sessionId);
|
||||
|
|
@ -1746,6 +1717,10 @@ async function selectSession(
|
|||
// missed durable events (or answers resync_required → snapshot).
|
||||
subscribeToSessionEvents(sessionId);
|
||||
}
|
||||
|
||||
// Refresh sidecars AFTER the snapshot settles so status/usage updates
|
||||
// aren't overwritten by syncSessionFromSnapshot.
|
||||
refreshSessionSidecars(sessionId);
|
||||
} catch (err) {
|
||||
rawState.warnings = [...rawState.warnings, `selectSession failed: ${String(err)}`];
|
||||
} finally {
|
||||
|
|
@ -2358,15 +2333,16 @@ async function logout(): Promise<void> {
|
|||
}
|
||||
|
||||
/**
|
||||
* compact() — request history compaction via POST /sessions/{id}:compact. The
|
||||
* compacted history is delivered asynchronously through the WS
|
||||
* history_compacted → onResync reload, so we just fire the request.
|
||||
* compact() — request history compaction via POST /sessions/{id}:compact.
|
||||
* Progress arrives asynchronously through the WS compaction.* events (running
|
||||
* notice → divider marker), so we just fire the request. An optional
|
||||
* instruction (from `/compact <text>`) steers what the summary focuses on.
|
||||
*/
|
||||
function compact(): void {
|
||||
function compact(instruction?: string): void {
|
||||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return;
|
||||
void getKimiWebApi()
|
||||
.compactSession(sid)
|
||||
.compactSession(sid, instruction)
|
||||
.catch((err) => {
|
||||
rawState.warnings = [...rawState.warnings, `compact failed: ${String(err)}`];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export default {
|
|||
thinking: { desc: 'Set the thinking level' },
|
||||
compact: { desc: 'Compact the conversation history' },
|
||||
fork: { desc: 'Fork this session into a new one' },
|
||||
compactNotImplemented: '/compact is not connected yet',
|
||||
undoNotImplemented: '/undo isn\'t supported by the daemon yet',
|
||||
status: { desc: 'View session status' },
|
||||
undo: { desc: 'Undo the last message' },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ export default {
|
|||
loading: 'Loading…',
|
||||
emptyWorkspaceHint: 'Send in {name}',
|
||||
compacting: 'Compacting context…',
|
||||
compacted: 'Context compacted ({before} → {after} tokens)',
|
||||
compactedPlain: 'Context compacted',
|
||||
compactedAuto: 'Context auto-compacted',
|
||||
compactedTokens: ' ({before} → {after} tokens)',
|
||||
viewSummary: 'View summary',
|
||||
summaryTitle: 'Compaction summary',
|
||||
manuallyAborted: 'Manually stopped',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export default {
|
|||
thinking: { desc: '设置思考强度' },
|
||||
compact: { desc: '压缩会话历史' },
|
||||
fork: { desc: '把当前会话 fork 出一个新会话' },
|
||||
compactNotImplemented: '尚未接入 /compact',
|
||||
undoNotImplemented: 'daemon 尚未支持 /undo',
|
||||
status: { desc: '查看会话状态' },
|
||||
undo: { desc: '撤销上一条消息' },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ export default {
|
|||
loading: '加载中…',
|
||||
emptyWorkspaceHint: '在 {name} 中发送',
|
||||
compacting: '正在压缩上下文…',
|
||||
compacted: '上下文已压缩({before} → {after} tokens)',
|
||||
compactedPlain: '上下文已压缩',
|
||||
compactedAuto: '已自动压缩上下文',
|
||||
compactedTokens: '({before} → {after} tokens)',
|
||||
viewSummary: '查看摘要',
|
||||
summaryTitle: '压缩摘要',
|
||||
manuallyAborted: '您已手动终止',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export type ApprovalBlock =
|
|||
| { kind: 'todo'; items: { title: string; status: string }[] }
|
||||
| { kind: 'generic'; summary: string };
|
||||
|
||||
export type TurnRole = 'user' | 'assistant';
|
||||
export type TurnRole = 'user' | 'assistant' | 'compaction';
|
||||
|
||||
export interface FilePreviewRequest {
|
||||
path: string;
|
||||
|
|
@ -140,6 +140,10 @@ export interface ChatTurn {
|
|||
approvalId?: string; // daemon approval id — present when approval needs a decision
|
||||
/** Image attachments sent by the user (rendered above the text bubble). */
|
||||
images?: { url: string; alt?: string }[];
|
||||
/** Compaction divider data (role 'compaction'): the transcript keeps all
|
||||
prior turns and renders this as a separator line; `text` holds the
|
||||
LLM-generated summary, opened in the right-side panel on click. */
|
||||
compaction?: { trigger?: 'manual' | 'auto'; tokensBefore?: number; tokensAfter?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
42
apps/kimi-web/test/__md-repro.test.ts
Normal file
42
apps/kimi-web/test/__md-repro.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// TEMP repro — markstream-vue rendering of a real chat message (delete after).
|
||||
import { describe, it } from 'vitest';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { MarkdownRender } from 'markstream-vue';
|
||||
|
||||
const text = readFileSync('/tmp/md-bug-sample.md', 'utf8');
|
||||
|
||||
describe('markstream repro', () => {
|
||||
it('streams the sample like the app (chunked, smooth-streaming)', async () => {
|
||||
const w = mount(MarkdownRender, {
|
||||
props: { content: '', mode: 'chat', final: false, smoothStreaming: true },
|
||||
});
|
||||
// Feed in small chunks like assistant deltas
|
||||
let acc = '';
|
||||
for (let i = 0; i < text.length; i += 24) {
|
||||
acc = text.slice(0, i + 24);
|
||||
await w.setProps({ content: acc });
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
}
|
||||
await w.setProps({ content: text, final: true, smoothStreaming: false });
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
writeFileSync('/tmp/md-repro-stream.html', w.html());
|
||||
});
|
||||
|
||||
it('renders with shiki code renderer (app config)', async () => {
|
||||
const w = mount(MarkdownRender, {
|
||||
props: {
|
||||
content: text,
|
||||
mode: 'chat',
|
||||
final: true,
|
||||
codeRenderer: 'shiki',
|
||||
isDark: false,
|
||||
codeBlockLightTheme: 'github-light',
|
||||
codeBlockDarkTheme: 'github-dark',
|
||||
themes: ['github-light', 'github-dark'],
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
writeFileSync('/tmp/md-repro-shiki.html', w.html());
|
||||
});
|
||||
});
|
||||
|
|
@ -1,19 +1,22 @@
|
|||
// apps/kimi-web/test/compaction.test.ts
|
||||
//
|
||||
// Compaction events stream through the REAL pipeline — projector → reducer —
|
||||
// and surface as per-session compaction status (running banner → done note),
|
||||
// plus the historyCompacted reload signal on completion.
|
||||
// and surface as per-session compaction status ("compacting…" notice while
|
||||
// running) plus a persistent divider marker message on completion. The
|
||||
// scrollback is never reloaded/replaced.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createAgentProjector } from '../src/api/daemon/agentEventProjector';
|
||||
import { createInitialState, reduceAppEvent, type KimiClientState } from '../src/api/daemon/eventReducer';
|
||||
import type { AppEvent } from '../src/api/types';
|
||||
import { COMPACTION_MARKER_METADATA_KEY, type AppEvent } from '../src/api/types';
|
||||
|
||||
const SESSION = 'sess_1';
|
||||
|
||||
function play(events: [string, unknown][]): { state: KimiClientState; appEvents: AppEvent[] } {
|
||||
const projector = createAgentProjector();
|
||||
let state = createInitialState();
|
||||
// The session transcript is loaded (the marker is only appended then).
|
||||
state = { ...state, messagesBySession: { [SESSION]: [] } };
|
||||
const appEvents: AppEvent[] = [];
|
||||
let seq = 0;
|
||||
for (const [type, payload] of events) {
|
||||
|
|
@ -36,19 +39,27 @@ describe('compaction pipeline', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('compaction.completed flips to a done note with token counts and signals a reload', () => {
|
||||
it('compaction.completed clears the running status and appends a divider marker', () => {
|
||||
const { state, appEvents } = play([
|
||||
['compaction.started', { trigger: 'auto' }],
|
||||
['compaction.completed', { result: { summary: 's', compactedCount: 12, tokensBefore: 90000, tokensAfter: 12000 } }],
|
||||
]);
|
||||
|
||||
expect(state.compactionBySession[SESSION]).toEqual({
|
||||
status: 'completed',
|
||||
// Running status is gone — completion is the marker, not transient status.
|
||||
expect(state.compactionBySession[SESSION]).toBeUndefined();
|
||||
|
||||
const msgs = state.messagesBySession[SESSION] ?? [];
|
||||
const marker = msgs[msgs.length - 1];
|
||||
expect(marker?.metadata?.['origin']).toEqual({ kind: 'compaction_summary' });
|
||||
expect(marker?.metadata?.[COMPACTION_MARKER_METADATA_KEY]).toEqual({
|
||||
trigger: 'auto',
|
||||
tokensBefore: 90000,
|
||||
tokensAfter: 12000,
|
||||
});
|
||||
// The reload signal must still fire (client routes it to onResync).
|
||||
expect(marker?.content).toEqual([{ type: 'text', text: 's' }]);
|
||||
|
||||
// The historyCompacted signal still fires (seq bookkeeping); the client
|
||||
// wrapper must NOT route compaction reasons to a snapshot reload.
|
||||
expect(appEvents.some((e) => e.type === 'historyCompacted')).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -60,12 +71,14 @@ describe('compaction pipeline', () => {
|
|||
expect(state.compactionBySession[SESSION]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a completed event without a prior started still produces a done note', () => {
|
||||
it('a completed event without a prior started still appends a marker', () => {
|
||||
const { state } = play([
|
||||
['compaction.completed', { result: { summary: 's', compactedCount: 3, tokensBefore: 50000, tokensAfter: 8000 } }],
|
||||
]);
|
||||
expect(state.compactionBySession[SESSION]).toMatchObject({
|
||||
status: 'completed',
|
||||
expect(state.compactionBySession[SESSION]).toBeUndefined();
|
||||
const msgs = state.messagesBySession[SESSION] ?? [];
|
||||
expect(msgs[msgs.length - 1]?.metadata?.[COMPACTION_MARKER_METADATA_KEY]).toMatchObject({
|
||||
trigger: 'auto',
|
||||
tokensBefore: 50000,
|
||||
tokensAfter: 8000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -353,6 +353,11 @@ export class SessionService extends Disposable implements ISessionService {
|
|||
throw new SessionNotFoundError(id);
|
||||
}
|
||||
|
||||
// beginCompaction only sees sessions loaded in core memory — resume first
|
||||
// (mirrors undo) so compacting a freshly-opened session doesn't throw
|
||||
// SESSION_NOT_FOUND.
|
||||
await this.core.rpc.resumeSession({ sessionId: id });
|
||||
|
||||
const instruction = normalizeOptionalString(input.instruction);
|
||||
await this.core.rpc.beginCompaction({
|
||||
sessionId: id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue