Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- apps/kimi-inspect/src/App.tsx +166 -0
- apps/kimi-inspect/src/activity/di.ts +53 -0
- apps/kimi-inspect/src/activity/store.test.ts +227 -0
- apps/kimi-inspect/src/activity/store.ts +160 -0
- apps/kimi-inspect/src/activity/useSessionActivity.ts +52 -0
- apps/kimi-inspect/src/activity/ws.ts +308 -0
- apps/kimi-inspect/src/audit/audit.test.ts +266 -0
- apps/kimi-inspect/src/audit/diff.ts +122 -0
- apps/kimi-inspect/src/audit/serialize.ts +43 -0
- apps/kimi-inspect/src/audit/trail.ts +198 -0
- apps/kimi-inspect/src/audit/truncate.ts +14 -0
- apps/kimi-inspect/src/channel/channel.test.ts +199 -0
- apps/kimi-inspect/src/channel/channel.ts +45 -0
- apps/kimi-inspect/src/channel/channels.ts +120 -0
- apps/kimi-inspect/src/channel/client.ts +81 -0
- apps/kimi-inspect/src/channel/errors.ts +16 -0
- apps/kimi-inspect/src/channel/index.ts +7 -0
- apps/kimi-inspect/src/channel/proxy.ts +21 -0
- apps/kimi-inspect/src/channel/proxyChannel.ts +74 -0
- apps/kimi-inspect/src/channel/wsLike.ts +20 -0
- apps/kimi-inspect/src/components/AppServicesView.tsx +27 -0
- apps/kimi-inspect/src/components/BashParserView.tsx +279 -0
- apps/kimi-inspect/src/components/ChatSearchBar.tsx +147 -0
- apps/kimi-inspect/src/components/ChatView.tsx +1231 -0
- apps/kimi-inspect/src/components/DiInspectionView.tsx +635 -0
- apps/kimi-inspect/src/components/FsSuggestView.tsx +256 -0
- apps/kimi-inspect/src/components/Inspector.tsx +308 -0
- apps/kimi-inspect/src/components/InteractionsCard.tsx +209 -0
- apps/kimi-inspect/src/components/ModelCatalogView.tsx +413 -0
- apps/kimi-inspect/src/components/NavRail.tsx +152 -0
- apps/kimi-inspect/src/components/RightPanel.tsx +105 -0
- apps/kimi-inspect/src/components/SearchView.tsx +254 -0
- apps/kimi-inspect/src/components/ServerSwitcher.tsx +48 -0
- apps/kimi-inspect/src/components/ServicePanels.tsx +805 -0
- apps/kimi-inspect/src/components/SessionPane.tsx +100 -0
- apps/kimi-inspect/src/components/Sidebar.tsx +602 -0
- apps/kimi-inspect/src/components/StateCard.tsx +117 -0
- apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx +211 -0
- apps/kimi-inspect/src/components/WorkspaceServicesView.tsx +138 -0
- apps/kimi-inspect/src/components/audit/AuditPanel.tsx +188 -0
- apps/kimi-inspect/src/components/audit/StateTree.test.tsx +161 -0
- apps/kimi-inspect/src/components/audit/StateTree.tsx +259 -0
- apps/kimi-inspect/src/components/di/DiEventsPanel.tsx +157 -0
- apps/kimi-inspect/src/components/di/DiGraphPanel.tsx +374 -0
- apps/kimi-inspect/src/components/methodArgs.test.ts +124 -0
- apps/kimi-inspect/src/components/methodArgs.ts +198 -0
- apps/kimi-inspect/src/connection.tsx +364 -0
- apps/kimi-inspect/src/fs/api.test.ts +100 -0
- apps/kimi-inspect/src/fs/api.ts +96 -0
- apps/kimi-inspect/src/index.css +33 -0
apps/kimi-inspect/src/App.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* App shell — selection state and session resume. There is no live event
|
| 3 |
+
* push anymore: the v2 socket (`/api/v2/ws`) that fed the core/session/agent
|
| 4 |
+
* event streams was removed server-side, so Service panels and the pending
|
| 5 |
+
* interactions card fetch on demand and the sidebar polls.
|
| 6 |
+
* Layout: header / icon rail / view. The `chat` view is a strip of the
|
| 7 |
+
* left sidebar (a workspace → session tree), the chat column, and the
|
| 8 |
+
* right dock (`RightPanel`) merging the transcript audit, the agent
|
| 9 |
+
* inspector, and the session pane under Audit / Agent / State / Session
|
| 10 |
+
* tabs;
|
| 11 |
+
* the `models` view is the full-width model catalog; the `services` view is
|
| 12 |
+
* the full-width app-scope Service reflection (`AppServicesView`); the
|
| 13 |
+
* `workspace` view is the workspace-scope counterpart
|
| 14 |
+
* (`WorkspaceServicesView`, with a workspace picker on top); the
|
| 15 |
+
* `bash` view is the full-width `IBashParserService` playground
|
| 16 |
+
* (`BashParserView`); the `di` view is the engine's Service × Effect × DI
|
| 17 |
+
* debug surface (`DiInspectionView`); the `search` view is the full-width
|
| 18 |
+
* global message search (`SearchView`) whose hits navigate back into the
|
| 19 |
+
* chat timeline.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager';
|
| 23 |
+
import { useEffect, useState } from 'react';
|
| 24 |
+
|
| 25 |
+
import type { AuditTrail } from './audit/trail';
|
| 26 |
+
import { AppServicesView } from './components/AppServicesView';
|
| 27 |
+
import { BashParserView } from './components/BashParserView';
|
| 28 |
+
import { ChatView, type ChatJump } from './components/ChatView';
|
| 29 |
+
import { DiInspectionView } from './components/DiInspectionView';
|
| 30 |
+
import { FsSuggestView } from './components/FsSuggestView';
|
| 31 |
+
import { ModelCatalogView } from './components/ModelCatalogView';
|
| 32 |
+
import { NavRail, type AppView } from './components/NavRail';
|
| 33 |
+
import { RightPanel } from './components/RightPanel';
|
| 34 |
+
import { SearchView } from './components/SearchView';
|
| 35 |
+
import { ServerSwitcher } from './components/ServerSwitcher';
|
| 36 |
+
import { Sidebar } from './components/Sidebar';
|
| 37 |
+
import { WorkspaceServicesView } from './components/WorkspaceServicesView';
|
| 38 |
+
import { useConnection } from './connection';
|
| 39 |
+
import type { SearchHit } from './search/api';
|
| 40 |
+
import { errorMessage } from './ui';
|
| 41 |
+
|
| 42 |
+
export function App() {
|
| 43 |
+
const { klient, baseUrl, disconnect } = useConnection();
|
| 44 |
+
const [sessionId, setSessionId] = useState<string | null>(null);
|
| 45 |
+
const [agentId, setAgentId] = useState('main');
|
| 46 |
+
const [view, setView] = useState<AppView>('chat');
|
| 47 |
+
const [ready, setReady] = useState(false);
|
| 48 |
+
const [resumeError, setResumeError] = useState<unknown>(null);
|
| 49 |
+
/** Audit trail of the chat view's transcript channel, rendered in the right dock. */
|
| 50 |
+
const [trail, setTrail] = useState<AuditTrail | null>(null);
|
| 51 |
+
/** Pending chat navigation requested from another view (search result click). */
|
| 52 |
+
const [jump, setJump] = useState<ChatJump | null>(null);
|
| 53 |
+
|
| 54 |
+
// Resume (materialize) the session on the server when it is selected, so
|
| 55 |
+
// session / agent scoped Services become reachable. Session lifecycle lives
|
| 56 |
+
// on the workspace handler (Workspace scope): the index yields the
|
| 57 |
+
// session's workspaceId, then the handler resumes it.
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
if (sessionId === null) return;
|
| 60 |
+
let cancelled = false;
|
| 61 |
+
setReady(false);
|
| 62 |
+
setResumeError(null);
|
| 63 |
+
klient
|
| 64 |
+
.core(ISessionManager)
|
| 65 |
+
.resume(sessionId)
|
| 66 |
+
.then((session) => {
|
| 67 |
+
if (session === undefined) throw new Error(`session ${sessionId} does not exist`);
|
| 68 |
+
})
|
| 69 |
+
.then(() => {
|
| 70 |
+
if (!cancelled) setReady(true);
|
| 71 |
+
})
|
| 72 |
+
.catch((error: unknown) => {
|
| 73 |
+
if (!cancelled) setResumeError(error);
|
| 74 |
+
});
|
| 75 |
+
return () => {
|
| 76 |
+
cancelled = true;
|
| 77 |
+
};
|
| 78 |
+
}, [klient, sessionId]);
|
| 79 |
+
|
| 80 |
+
// Switching servers invalidates every session/agent selection: sessions
|
| 81 |
+
// belong to the server they were listed from.
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
setSessionId(null);
|
| 84 |
+
setAgentId('main');
|
| 85 |
+
setJump(null);
|
| 86 |
+
}, [baseUrl]);
|
| 87 |
+
|
| 88 |
+
// A search hit opens the chat view at its session / agent / turn / step.
|
| 89 |
+
// Title hits belong to the session (agent '') and carry no turn: switch
|
| 90 |
+
// over without a scroll target.
|
| 91 |
+
const openSearchHit = (hit: SearchHit): void => {
|
| 92 |
+
setSessionId(hit.sessionId);
|
| 93 |
+
setAgentId(hit.agentId === '' ? 'main' : hit.agentId);
|
| 94 |
+
setView('chat');
|
| 95 |
+
setJump({
|
| 96 |
+
turnId: hit.turn === undefined ? undefined : `t${hit.turn}`,
|
| 97 |
+
stepId: hit.stepId,
|
| 98 |
+
nonce: Date.now(),
|
| 99 |
+
});
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<div className="flex h-screen flex-col">
|
| 104 |
+
<header className="flex items-center gap-3 border-b border-neutral-800 px-4 py-1.5">
|
| 105 |
+
<span className="text-[12px] font-bold tracking-widest text-neutral-200">KIMI INSPECT</span>
|
| 106 |
+
<ServerSwitcher />
|
| 107 |
+
<div className="flex-1" />
|
| 108 |
+
<button
|
| 109 |
+
className="text-[11px] text-neutral-500 hover:text-neutral-300"
|
| 110 |
+
onClick={disconnect}
|
| 111 |
+
>
|
| 112 |
+
Disconnect
|
| 113 |
+
</button>
|
| 114 |
+
</header>
|
| 115 |
+
<div className="flex min-h-0 flex-1">
|
| 116 |
+
<NavRail view={view} onChange={setView} />
|
| 117 |
+
{view === 'services' ? (
|
| 118 |
+
<AppServicesView />
|
| 119 |
+
) : view === 'workspace' ? (
|
| 120 |
+
<WorkspaceServicesView />
|
| 121 |
+
) : view === 'suggest' ? (
|
| 122 |
+
<FsSuggestView />
|
| 123 |
+
) : view === 'bash' ? (
|
| 124 |
+
<BashParserView />
|
| 125 |
+
) : view === 'di' ? (
|
| 126 |
+
<DiInspectionView />
|
| 127 |
+
) : view === 'models' ? (
|
| 128 |
+
<ModelCatalogView
|
| 129 |
+
onOpenSession={(id) => {
|
| 130 |
+
setSessionId(id);
|
| 131 |
+
setView('chat');
|
| 132 |
+
}}
|
| 133 |
+
/>
|
| 134 |
+
) : view === 'search' ? (
|
| 135 |
+
<SearchView onOpenResult={openSearchHit} />
|
| 136 |
+
) : (
|
| 137 |
+
<>
|
| 138 |
+
<Sidebar activeSessionId={sessionId} onSelectSession={setSessionId} />
|
| 139 |
+
{resumeError !== null ? (
|
| 140 |
+
<div className="flex flex-1 items-center justify-center p-6 text-center text-[12px] text-red-400">
|
| 141 |
+
Failed to open session: {errorMessage(resumeError)}
|
| 142 |
+
</div>
|
| 143 |
+
) : (
|
| 144 |
+
<ChatView
|
| 145 |
+
sessionId={sessionId}
|
| 146 |
+
agentId={agentId}
|
| 147 |
+
ready={ready}
|
| 148 |
+
onTrailChange={setTrail}
|
| 149 |
+
jump={jump}
|
| 150 |
+
onJumpHandled={() => setJump(null)}
|
| 151 |
+
onOpenSearchHit={openSearchHit}
|
| 152 |
+
/>
|
| 153 |
+
)}
|
| 154 |
+
<RightPanel
|
| 155 |
+
sessionId={sessionId}
|
| 156 |
+
agentId={agentId}
|
| 157 |
+
onAgentChange={setAgentId}
|
| 158 |
+
ready={ready}
|
| 159 |
+
trail={trail}
|
| 160 |
+
/>
|
| 161 |
+
</>
|
| 162 |
+
)}
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
);
|
| 166 |
+
}
|
apps/kimi-inspect/src/activity/di.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* DI debug feed — a dedicated global-events socket for the DI view. The
|
| 3 |
+
* session activity hub (`useSessionActivities`) lives with the chat Sidebar,
|
| 4 |
+
* which unmounts when the DI view is active, so the DI view owns its own
|
| 5 |
+
* `GlobalEventsWs` (only one of the two is ever connected at a time).
|
| 6 |
+
*
|
| 7 |
+
* Every `event.di.unit_changed` frame invalidates the `['di']` query prefix
|
| 8 |
+
* (the same pattern as `event.session.created` invalidating `['sessions']`),
|
| 9 |
+
* so all DI panels refetch on unit transitions instead of waiting out their
|
| 10 |
+
* poll interval. Bursts (a cascade flipping many units at once) are coalesced
|
| 11 |
+
* with a short trailing throttle; a reconnect invalidates immediately, since
|
| 12 |
+
* live transitions were missed while the socket was down.
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import { useQueryClient } from '@tanstack/react-query';
|
| 16 |
+
import { useEffect } from 'react';
|
| 17 |
+
|
| 18 |
+
import { useConnection } from '../connection';
|
| 19 |
+
import { GlobalEventsWs } from './ws';
|
| 20 |
+
|
| 21 |
+
const INVALIDATE_THROTTLE_MS = 250;
|
| 22 |
+
|
| 23 |
+
export function useDiQueryInvalidation(): void {
|
| 24 |
+
const { baseUrl, config } = useConnection();
|
| 25 |
+
const queryClient = useQueryClient();
|
| 26 |
+
const token = config.token.trim();
|
| 27 |
+
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
| 30 |
+
const invalidate = () => {
|
| 31 |
+
if (timer !== undefined) return;
|
| 32 |
+
timer = setTimeout(() => {
|
| 33 |
+
timer = undefined;
|
| 34 |
+
void queryClient.invalidateQueries({ queryKey: ['di'] });
|
| 35 |
+
}, INVALIDATE_THROTTLE_MS);
|
| 36 |
+
};
|
| 37 |
+
const ws = new GlobalEventsWs({
|
| 38 |
+
url: baseUrl,
|
| 39 |
+
token: token === '' ? undefined : token,
|
| 40 |
+
handlers: {
|
| 41 |
+
onWorkChanged: () => {},
|
| 42 |
+
onSessionCreated: () => {},
|
| 43 |
+
onMetaUpdated: () => {},
|
| 44 |
+
onDiUnitChanged: invalidate,
|
| 45 |
+
onReconnected: invalidate,
|
| 46 |
+
},
|
| 47 |
+
});
|
| 48 |
+
return () => {
|
| 49 |
+
if (timer !== undefined) clearTimeout(timer);
|
| 50 |
+
ws.close();
|
| 51 |
+
};
|
| 52 |
+
}, [baseUrl, token, queryClient]);
|
| 53 |
+
}
|
apps/kimi-inspect/src/activity/store.test.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi } from 'vitest';
|
| 2 |
+
|
| 3 |
+
import type { WsLike, WsLikeCtor } from '../channel/wsLike';
|
| 4 |
+
import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store';
|
| 5 |
+
|
| 6 |
+
function facts(partial: Partial<SessionWorkFacts> = {}): SessionWorkFacts {
|
| 7 |
+
return {
|
| 8 |
+
busy: false,
|
| 9 |
+
mainTurnActive: false,
|
| 10 |
+
pendingInteraction: 'none',
|
| 11 |
+
lastTurnReason: undefined,
|
| 12 |
+
...partial,
|
| 13 |
+
};
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
class FakeWs implements WsLike {
|
| 17 |
+
static readonly OPEN = 1;
|
| 18 |
+
readyState = 1;
|
| 19 |
+
sent: string[] = [];
|
| 20 |
+
closed = false;
|
| 21 |
+
private readonly listeners = new Map<string, Array<(event: never) => void>>();
|
| 22 |
+
|
| 23 |
+
send(data: string): void {
|
| 24 |
+
this.sent.push(data);
|
| 25 |
+
}
|
| 26 |
+
close(): void {
|
| 27 |
+
this.closed = true;
|
| 28 |
+
}
|
| 29 |
+
addEventListener(type: string, listener: (event: never) => void): void {
|
| 30 |
+
const list = this.listeners.get(type) ?? [];
|
| 31 |
+
list.push(listener);
|
| 32 |
+
this.listeners.set(type, list);
|
| 33 |
+
}
|
| 34 |
+
emit(type: 'open' | 'message' | 'close' | 'error', event?: unknown): void {
|
| 35 |
+
for (const listener of this.listeners.get(type) ?? []) listener(event as never);
|
| 36 |
+
}
|
| 37 |
+
emitFrame(frame: Record<string, unknown>): void {
|
| 38 |
+
this.emit('message', { data: JSON.stringify(frame) });
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function makeFakeWsCtor(): { ctor: WsLikeCtor; instances: FakeWs[] } {
|
| 43 |
+
const instances: FakeWs[] = [];
|
| 44 |
+
const ctor = class {
|
| 45 |
+
static readonly OPEN = 1;
|
| 46 |
+
constructor(_url: string, _protocols?: string | string[]) {
|
| 47 |
+
const ws = new FakeWs();
|
| 48 |
+
instances.push(ws);
|
| 49 |
+
return ws;
|
| 50 |
+
}
|
| 51 |
+
} as unknown as WsLikeCtor;
|
| 52 |
+
return { ctor, instances };
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function seedFetch(items: Record<string, unknown>[]): typeof fetch {
|
| 56 |
+
return vi.fn(async () => ({
|
| 57 |
+
json: async () => ({ code: 0, data: { items, has_more: false } }),
|
| 58 |
+
})) as unknown as typeof fetch;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
describe('SessionActivityStore', () => {
|
| 62 |
+
it('applies work facts and notifies with a version bump', () => {
|
| 63 |
+
const store = new SessionActivityStore();
|
| 64 |
+
const listener = vi.fn();
|
| 65 |
+
store.subscribe(listener);
|
| 66 |
+
|
| 67 |
+
store.applyWorkChanged('s1', facts({ busy: true, mainTurnActive: true }));
|
| 68 |
+
|
| 69 |
+
expect(store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true }));
|
| 70 |
+
expect(store.getVersion()).toBe(1);
|
| 71 |
+
expect(listener).toHaveBeenCalledTimes(1);
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
it('ignores identical facts (no bump, no notify)', () => {
|
| 75 |
+
const store = new SessionActivityStore();
|
| 76 |
+
const listener = vi.fn();
|
| 77 |
+
store.applyWorkChanged('s1', facts({ busy: true }));
|
| 78 |
+
store.subscribe(listener);
|
| 79 |
+
|
| 80 |
+
store.applyWorkChanged('s1', facts({ busy: true }));
|
| 81 |
+
|
| 82 |
+
expect(store.getVersion()).toBe(1);
|
| 83 |
+
expect(listener).not.toHaveBeenCalled();
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
it('seed replaces the whole map', () => {
|
| 87 |
+
const store = new SessionActivityStore();
|
| 88 |
+
store.applyWorkChanged('stale', facts({ busy: true }));
|
| 89 |
+
|
| 90 |
+
store.seed([['s1', facts({ pendingInteraction: 'approval' })]]);
|
| 91 |
+
|
| 92 |
+
expect(store.get('stale')).toBeUndefined();
|
| 93 |
+
expect(store.get('s1')?.pendingInteraction).toBe('approval');
|
| 94 |
+
});
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
describe('SessionActivityHub', () => {
|
| 98 |
+
it('seeds the store from the REST session list when the socket opens', async () => {
|
| 99 |
+
const { ctor, instances } = makeFakeWsCtor();
|
| 100 |
+
const hub = new SessionActivityHub({
|
| 101 |
+
url: 'http://127.0.0.1:58627',
|
| 102 |
+
onListChanged: () => {},
|
| 103 |
+
WebSocketImpl: ctor,
|
| 104 |
+
fetchImpl: seedFetch([
|
| 105 |
+
{ id: 's1', busy: true, main_turn_active: true, pending_interaction: 'none' },
|
| 106 |
+
{ id: 's2', busy: false, main_turn_active: false, pending_interaction: 'approval' },
|
| 107 |
+
]),
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
instances[0]!.emit('open');
|
| 111 |
+
await vi.waitFor(() => expect(hub.store.get('s1')).toBeDefined());
|
| 112 |
+
|
| 113 |
+
expect(hub.store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true }));
|
| 114 |
+
expect(hub.store.get('s2')?.pendingInteraction).toBe('approval');
|
| 115 |
+
// The hello goes out with no subscriptions — global facts flow regardless.
|
| 116 |
+
const hello = JSON.parse(instances[0]!.sent[0]!) as {
|
| 117 |
+
type: string;
|
| 118 |
+
payload: { subscriptions: string[] };
|
| 119 |
+
};
|
| 120 |
+
expect(hello.type).toBe('client_hello');
|
| 121 |
+
expect(hello.payload.subscriptions).toEqual([]);
|
| 122 |
+
hub.close();
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it('applies live work_changed frames by session id', () => {
|
| 126 |
+
const { ctor, instances } = makeFakeWsCtor();
|
| 127 |
+
const hub = new SessionActivityHub({
|
| 128 |
+
url: 'http://127.0.0.1:58627',
|
| 129 |
+
onListChanged: () => {},
|
| 130 |
+
WebSocketImpl: ctor,
|
| 131 |
+
fetchImpl: seedFetch([]),
|
| 132 |
+
});
|
| 133 |
+
instances[0]!.emit('open');
|
| 134 |
+
|
| 135 |
+
instances[0]!.emitFrame({
|
| 136 |
+
type: 'event.session.work_changed',
|
| 137 |
+
session_id: 's1',
|
| 138 |
+
payload: {
|
| 139 |
+
type: 'event.session.work_changed',
|
| 140 |
+
busy: true,
|
| 141 |
+
main_turn_active: true,
|
| 142 |
+
pending_interaction: 'question',
|
| 143 |
+
last_turn_reason: null,
|
| 144 |
+
},
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
expect(hub.store.get('s1')).toEqual(
|
| 148 |
+
facts({ busy: true, mainTurnActive: true, pendingInteraction: 'question' }),
|
| 149 |
+
);
|
| 150 |
+
hub.close();
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
it('forwards created and meta updates as list-level signals', () => {
|
| 154 |
+
const { ctor, instances } = makeFakeWsCtor();
|
| 155 |
+
const onListChanged = vi.fn();
|
| 156 |
+
const hub = new SessionActivityHub({
|
| 157 |
+
url: 'http://127.0.0.1:58627',
|
| 158 |
+
onListChanged,
|
| 159 |
+
WebSocketImpl: ctor,
|
| 160 |
+
fetchImpl: seedFetch([]),
|
| 161 |
+
});
|
| 162 |
+
instances[0]!.emit('open');
|
| 163 |
+
|
| 164 |
+
instances[0]!.emitFrame({ type: 'event.session.created', session_id: 's1', payload: {} });
|
| 165 |
+
instances[0]!.emitFrame({ type: 'session.meta.updated', session_id: 's1', payload: {} });
|
| 166 |
+
// Agent-grained frames are ignored even if they somehow arrive.
|
| 167 |
+
instances[0]!.emitFrame({ type: 'turn.started', session_id: 's1', payload: {} });
|
| 168 |
+
|
| 169 |
+
expect(onListChanged).toHaveBeenCalledTimes(2);
|
| 170 |
+
expect(hub.store.get('s1')).toBeUndefined();
|
| 171 |
+
hub.close();
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
it('forwards archived and workspace frames as list-level signals and drops archived facts', () => {
|
| 175 |
+
const { ctor, instances } = makeFakeWsCtor();
|
| 176 |
+
const onListChanged = vi.fn();
|
| 177 |
+
const hub = new SessionActivityHub({
|
| 178 |
+
url: 'http://127.0.0.1:58627',
|
| 179 |
+
onListChanged,
|
| 180 |
+
WebSocketImpl: ctor,
|
| 181 |
+
fetchImpl: seedFetch([]),
|
| 182 |
+
});
|
| 183 |
+
instances[0]!.emit('open');
|
| 184 |
+
|
| 185 |
+
instances[0]!.emitFrame({
|
| 186 |
+
type: 'event.session.work_changed',
|
| 187 |
+
session_id: 's1',
|
| 188 |
+
payload: { type: 'event.session.work_changed', busy: true },
|
| 189 |
+
});
|
| 190 |
+
expect(hub.store.get('s1')).toBeDefined();
|
| 191 |
+
|
| 192 |
+
// Global-dispatched frames carry the __global__ watermark; the real
|
| 193 |
+
// session id rides in the payload.
|
| 194 |
+
instances[0]!.emitFrame({
|
| 195 |
+
type: 'event.session.archived',
|
| 196 |
+
session_id: '__global__',
|
| 197 |
+
payload: { type: 'event.session.archived', sessionId: 's1', workspace_id: 'wd_1' },
|
| 198 |
+
});
|
| 199 |
+
expect(hub.store.get('s1')).toBeUndefined();
|
| 200 |
+
expect(onListChanged).toHaveBeenCalledTimes(1);
|
| 201 |
+
|
| 202 |
+
instances[0]!.emitFrame({
|
| 203 |
+
type: 'event.session.work_changed',
|
| 204 |
+
session_id: 's2',
|
| 205 |
+
payload: { type: 'event.session.work_changed', busy: true },
|
| 206 |
+
});
|
| 207 |
+
expect(hub.store.get('s2')).toBeDefined();
|
| 208 |
+
|
| 209 |
+
instances[0]!.emitFrame({
|
| 210 |
+
type: 'event.session.deleted',
|
| 211 |
+
session_id: '__global__',
|
| 212 |
+
payload: { type: 'event.session.deleted', sessionId: 's2', workspace_id: 'wd_1' },
|
| 213 |
+
});
|
| 214 |
+
expect(hub.store.get('s2')).toBeUndefined();
|
| 215 |
+
expect(onListChanged).toHaveBeenCalledTimes(2);
|
| 216 |
+
|
| 217 |
+
for (const type of [
|
| 218 |
+
'event.workspace.created',
|
| 219 |
+
'event.workspace.updated',
|
| 220 |
+
'event.workspace.deleted',
|
| 221 |
+
]) {
|
| 222 |
+
instances[0]!.emitFrame({ type, session_id: '__global__', payload: {} });
|
| 223 |
+
}
|
| 224 |
+
expect(onListChanged).toHaveBeenCalledTimes(5);
|
| 225 |
+
hub.close();
|
| 226 |
+
});
|
| 227 |
+
});
|
apps/kimi-inspect/src/activity/store.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Session activity hub — owns the global-events socket and the per-session
|
| 3 |
+
* coarse activity map behind the Sidebar's status badges.
|
| 4 |
+
*
|
| 5 |
+
* Two data sources converge into one store: the initial / reconnect
|
| 6 |
+
* baseline comes from a single `GET /api/v1/sessions` page (every wire
|
| 7 |
+
* session carries `busy` / `main_turn_active` / `pending_interaction` /
|
| 8 |
+
* `last_turn_reason`), and live updates arrive as
|
| 9 |
+
* `event.session.work_changed` frames over the global WS channel (no
|
| 10 |
+
* subscription needed server-side). List-level facts (session created /
|
| 11 |
+
* retitled) are forwarded to the consumer as `onListChanged` so the
|
| 12 |
+
* react-query session list invalidates instead of waiting out its slow poll.
|
| 13 |
+
* The store is a plain subscribe/version store so React binds through
|
| 14 |
+
* `useSyncExternalStore`.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import type { WsLikeCtor } from '../channel/wsLike';
|
| 18 |
+
import { GlobalEventsWs, type SessionWorkFacts } from './ws';
|
| 19 |
+
|
| 20 |
+
export type { SessionWorkFacts };
|
| 21 |
+
|
| 22 |
+
export class SessionActivityStore {
|
| 23 |
+
private activities = new Map<string, SessionWorkFacts>();
|
| 24 |
+
private readonly listeners = new Set<() => void>();
|
| 25 |
+
private version = 0;
|
| 26 |
+
|
| 27 |
+
get(sessionId: string): SessionWorkFacts | undefined {
|
| 28 |
+
return this.activities.get(sessionId);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
getVersion(): number {
|
| 32 |
+
return this.version;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
subscribe(listener: () => void): () => void {
|
| 36 |
+
this.listeners.add(listener);
|
| 37 |
+
return () => this.listeners.delete(listener);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
applyWorkChanged(sessionId: string, facts: SessionWorkFacts): void {
|
| 41 |
+
const previous = this.activities.get(sessionId);
|
| 42 |
+
if (
|
| 43 |
+
previous !== undefined &&
|
| 44 |
+
previous.busy === facts.busy &&
|
| 45 |
+
previous.mainTurnActive === facts.mainTurnActive &&
|
| 46 |
+
previous.pendingInteraction === facts.pendingInteraction &&
|
| 47 |
+
previous.lastTurnReason === facts.lastTurnReason
|
| 48 |
+
) {
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
this.activities.set(sessionId, facts);
|
| 52 |
+
this.bump();
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/** Replace the whole map with a REST baseline (initial load / re-seed). */
|
| 56 |
+
seed(entries: Iterable<readonly [string, SessionWorkFacts]>): void {
|
| 57 |
+
this.activities = new Map(entries);
|
| 58 |
+
this.bump();
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/** Drop one session's live facts (e.g. it was archived — no further
|
| 62 |
+
* work_changed frames will arrive to correct a stale badge). */
|
| 63 |
+
remove(sessionId: string): void {
|
| 64 |
+
if (this.activities.delete(sessionId)) this.bump();
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
private bump(): void {
|
| 68 |
+
this.version += 1;
|
| 69 |
+
for (const listener of this.listeners) listener();
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export interface SessionActivityHubOptions {
|
| 74 |
+
/** Server base URL (`http(s)://host:port`). */
|
| 75 |
+
readonly url: string;
|
| 76 |
+
readonly token?: string | undefined;
|
| 77 |
+
/** List-level signal (session created / retitled) — invalidate the list. */
|
| 78 |
+
readonly onListChanged: () => void;
|
| 79 |
+
readonly WebSocketImpl?: WsLikeCtor;
|
| 80 |
+
readonly fetchImpl?: typeof fetch;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export class SessionActivityHub {
|
| 84 |
+
readonly store = new SessionActivityStore();
|
| 85 |
+
private readonly ws: GlobalEventsWs;
|
| 86 |
+
private readonly baseUrl: string;
|
| 87 |
+
private readonly token?: string;
|
| 88 |
+
private readonly fetchImpl: typeof fetch;
|
| 89 |
+
|
| 90 |
+
constructor(opts: SessionActivityHubOptions) {
|
| 91 |
+
this.baseUrl = opts.url.replace(/\/$/, '');
|
| 92 |
+
this.token = opts.token;
|
| 93 |
+
// Bind the default: `this.fetchImpl(...)` is a member call, and the
|
| 94 |
+
// browser's `fetch` throws Illegal invocation when its receiver is not
|
| 95 |
+
// the global object (Node's undici fetch does not care).
|
| 96 |
+
this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis);
|
| 97 |
+
this.ws = new GlobalEventsWs({
|
| 98 |
+
url: opts.url,
|
| 99 |
+
token: opts.token,
|
| 100 |
+
WebSocketImpl: opts.WebSocketImpl,
|
| 101 |
+
handlers: {
|
| 102 |
+
onWorkChanged: (sessionId, facts) => this.store.applyWorkChanged(sessionId, facts),
|
| 103 |
+
onSessionCreated: () => opts.onListChanged(),
|
| 104 |
+
onMetaUpdated: () => opts.onListChanged(),
|
| 105 |
+
onSessionArchived: (sessionId) => {
|
| 106 |
+
this.store.remove(sessionId);
|
| 107 |
+
opts.onListChanged();
|
| 108 |
+
},
|
| 109 |
+
onSessionDeleted: (sessionId) => {
|
| 110 |
+
this.store.remove(sessionId);
|
| 111 |
+
opts.onListChanged();
|
| 112 |
+
},
|
| 113 |
+
onWorkspaceChanged: () => opts.onListChanged(),
|
| 114 |
+
onReconnected: () => void this.seed(),
|
| 115 |
+
},
|
| 116 |
+
});
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
close(): void {
|
| 120 |
+
this.ws.close();
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
private async seed(): Promise<void> {
|
| 124 |
+
const headers: Record<string, string> = {};
|
| 125 |
+
if (this.token !== undefined && this.token.length > 0) {
|
| 126 |
+
headers['authorization'] = `Bearer ${this.token}`;
|
| 127 |
+
}
|
| 128 |
+
try {
|
| 129 |
+
const res = await this.fetchImpl(`${this.baseUrl}/api/v1/sessions`, { headers });
|
| 130 |
+
const envelope = (await res.json()) as {
|
| 131 |
+
code: number;
|
| 132 |
+
data?: { items?: Record<string, unknown>[] };
|
| 133 |
+
};
|
| 134 |
+
if (envelope.code !== 0 || envelope.data?.items === undefined) return;
|
| 135 |
+
const entries: [string, SessionWorkFacts][] = [];
|
| 136 |
+
for (const item of envelope.data.items) {
|
| 137 |
+
const id = item['id'];
|
| 138 |
+
if (typeof id !== 'string' || typeof item['busy'] !== 'boolean') continue;
|
| 139 |
+
const pending = item['pending_interaction'];
|
| 140 |
+
const reason = item['last_turn_reason'];
|
| 141 |
+
entries.push([
|
| 142 |
+
id,
|
| 143 |
+
{
|
| 144 |
+
busy: item['busy'],
|
| 145 |
+
mainTurnActive: item['main_turn_active'] === true,
|
| 146 |
+
pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none',
|
| 147 |
+
lastTurnReason:
|
| 148 |
+
reason === 'completed' || reason === 'cancelled' || reason === 'failed'
|
| 149 |
+
? reason
|
| 150 |
+
: undefined,
|
| 151 |
+
},
|
| 152 |
+
]);
|
| 153 |
+
}
|
| 154 |
+
this.store.seed(entries);
|
| 155 |
+
} catch {
|
| 156 |
+
// Seed is best-effort: live frames keep flowing, and the next reconnect
|
| 157 |
+
// re-seeds. A dead server surfaces through the connection layer anyway.
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
}
|
apps/kimi-inspect/src/activity/useSessionActivity.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* React binding for the session activity hub: one hub per (server, token),
|
| 3 |
+
* torn down on server switch or unmount. Consumers read per-session coarse
|
| 4 |
+
* activity (`get(sessionId)`) and re-render on every store bump; list-level
|
| 5 |
+
* signals invalidate the `['sessions']` / `['v2-sessions']` react-query lists
|
| 6 |
+
* directly.
|
| 7 |
+
*
|
| 8 |
+
* The hub is created inside `useEffect` (not `useMemo`): under StrictMode
|
| 9 |
+
* the mount → cleanup → re-mount cycle runs the cleanup of the FIRST mount,
|
| 10 |
+
* and a memo-created hub would stay closed for the rest of the page's life.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { useQueryClient } from '@tanstack/react-query';
|
| 14 |
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
| 15 |
+
|
| 16 |
+
import { useConnection } from '../connection';
|
| 17 |
+
import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store';
|
| 18 |
+
|
| 19 |
+
const EMPTY_STORE = new SessionActivityStore();
|
| 20 |
+
|
| 21 |
+
export function useSessionActivities(): {
|
| 22 |
+
get(sessionId: string): SessionWorkFacts | undefined;
|
| 23 |
+
} {
|
| 24 |
+
const { baseUrl, config } = useConnection();
|
| 25 |
+
const queryClient = useQueryClient();
|
| 26 |
+
const token = config.token.trim();
|
| 27 |
+
const [hub, setHub] = useState<SessionActivityHub | null>(null);
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
const created = new SessionActivityHub({
|
| 31 |
+
url: baseUrl,
|
| 32 |
+
token: token === '' ? undefined : token,
|
| 33 |
+
onListChanged: () => {
|
| 34 |
+
void queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
| 35 |
+
void queryClient.invalidateQueries({ queryKey: ['v2-sessions'] });
|
| 36 |
+
void queryClient.invalidateQueries({ queryKey: ['workspaces'] });
|
| 37 |
+
},
|
| 38 |
+
});
|
| 39 |
+
setHub(created);
|
| 40 |
+
return () => {
|
| 41 |
+
setHub(null);
|
| 42 |
+
created.close();
|
| 43 |
+
};
|
| 44 |
+
}, [baseUrl, token, queryClient]);
|
| 45 |
+
|
| 46 |
+
const store = hub?.store ?? EMPTY_STORE;
|
| 47 |
+
useSyncExternalStore(
|
| 48 |
+
(listener) => store.subscribe(listener),
|
| 49 |
+
() => store.getVersion(),
|
| 50 |
+
);
|
| 51 |
+
return store;
|
| 52 |
+
}
|
apps/kimi-inspect/src/activity/ws.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Minimal `/api/v1/ws` client for GLOBAL session facts — no subscriptions.
|
| 3 |
+
*
|
| 4 |
+
* The server pushes every global event (`event.session.*` /
|
| 5 |
+
* `session.meta.updated` / `event.workspace.*` / `event.config.*`) to every
|
| 6 |
+
* established connection, so this client subscribes to nothing: it sends a
|
| 7 |
+
* `client_hello` with an empty subscription list (etiquette only — the
|
| 8 |
+
* delivery set does not depend on it) and dispatches the coarse per-session
|
| 9 |
+
* facts to the consumer:
|
| 10 |
+
*
|
| 11 |
+
* - `event.session.work_changed` → `{busy, main_turn_active,
|
| 12 |
+
* pending_interaction, last_turn_reason}` for one session;
|
| 13 |
+
* - `event.session.created` / `session.meta.updated` → list-level signals
|
| 14 |
+
* (a session appeared / retitled), forwarded for list invalidation;
|
| 15 |
+
* - `event.session.archived` (live or cold) / `event.workspace.*` →
|
| 16 |
+
* list-level signals, forwarded for list invalidation;
|
| 17 |
+
* - `event.di.unit_changed` → one DI unit state transition of the engine's
|
| 18 |
+
* scope tree (the debug-surface feed), forwarded for `['di']`
|
| 19 |
+
* invalidation. Global like the rest: it carries the `__global__`
|
| 20 |
+
* session watermark and fans out to every connection.
|
| 21 |
+
*
|
| 22 |
+
* Session/agent-grained events never arrive here (they stay subscribe-gated
|
| 23 |
+
* server-side); the transcript chat channel has its own socket. Global
|
| 24 |
+
* frames are live-only — a drop loses whatever fired meanwhile, so the
|
| 25 |
+
* consumer answers `onReconnected` with a REST re-seed.
|
| 26 |
+
*
|
| 27 |
+
* The bearer token is presented at the upgrade through the
|
| 28 |
+
* `kimi-code.bearer.<token>` subprotocol (the only credential channel a
|
| 29 |
+
* browser WebSocket has).
|
| 30 |
+
*/
|
| 31 |
+
|
| 32 |
+
import type { WsLike, WsLikeCtor } from '../channel/wsLike';
|
| 33 |
+
|
| 34 |
+
export type SessionPendingInteraction = 'none' | 'approval' | 'question';
|
| 35 |
+
export type SessionTurnOutcome = 'completed' | 'cancelled' | 'failed';
|
| 36 |
+
|
| 37 |
+
export interface SessionWorkFacts {
|
| 38 |
+
readonly busy: boolean;
|
| 39 |
+
readonly mainTurnActive: boolean;
|
| 40 |
+
readonly pendingInteraction: SessionPendingInteraction;
|
| 41 |
+
readonly lastTurnReason?: SessionTurnOutcome | undefined;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export type DiUnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed';
|
| 45 |
+
|
| 46 |
+
/** Wire payload of the `event.di.unit_changed` global event. */
|
| 47 |
+
export interface DiUnitChangedPayload {
|
| 48 |
+
/** Scope path of the container owning the unit (`app` / `app/workspace:<id>` / …). */
|
| 49 |
+
readonly scope: string;
|
| 50 |
+
readonly token: string;
|
| 51 |
+
readonly state: DiUnitState;
|
| 52 |
+
/** Serialized sticky failure, present only on a Failed transition. */
|
| 53 |
+
readonly error?: string | undefined;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export interface GlobalEventsWsHandlers {
|
| 57 |
+
/** Coarse work-fact tuple for one session changed. */
|
| 58 |
+
onWorkChanged: (sessionId: string, facts: SessionWorkFacts) => void;
|
| 59 |
+
/** A session was created (list-level signal). */
|
| 60 |
+
onSessionCreated: (sessionId: string) => void;
|
| 61 |
+
/** A session's title/patch changed (list-level signal). */
|
| 62 |
+
onMetaUpdated: (sessionId: string) => void;
|
| 63 |
+
/** A session was archived, live or cold (list-level signal). The envelope
|
| 64 |
+
* carries the `__global__` watermark; the real session id rides in the
|
| 65 |
+
* payload. */
|
| 66 |
+
onSessionArchived?: ((sessionId: string) => void) | undefined;
|
| 67 |
+
/** A session was permanently deleted (list-level signal). Same envelope
|
| 68 |
+
* shape as `event.session.archived`: the real session id rides in the
|
| 69 |
+
* payload. */
|
| 70 |
+
onSessionDeleted?: (sessionId: string) => void;
|
| 71 |
+
/** A workspace was created / updated / deleted (list-level signal). */
|
| 72 |
+
onWorkspaceChanged?: (() => void) | undefined;
|
| 73 |
+
/** A DI unit of the engine's scope tree changed state (debug feed). */
|
| 74 |
+
onDiUnitChanged?: ((payload: DiUnitChangedPayload) => void) | undefined;
|
| 75 |
+
/** Socket established (initial connect and every reconnect) — the consumer
|
| 76 |
+
* answers with a REST re-seed, since live facts are missed while down. */
|
| 77 |
+
onReconnected: () => void;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export interface GlobalEventsWsOptions {
|
| 81 |
+
/** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v1/ws` URL. */
|
| 82 |
+
readonly url: string;
|
| 83 |
+
readonly token?: string | undefined;
|
| 84 |
+
readonly handlers: GlobalEventsWsHandlers;
|
| 85 |
+
/** WebSocket implementation; defaults to the global `WebSocket`. */
|
| 86 |
+
readonly WebSocketImpl?: WsLikeCtor;
|
| 87 |
+
/** Base delay (ms) for the reconnect backoff. Default `500`. */
|
| 88 |
+
readonly reconnectDelayMs?: number;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
interface ServerFrame {
|
| 92 |
+
readonly type: string;
|
| 93 |
+
readonly id?: string;
|
| 94 |
+
readonly session_id?: string;
|
| 95 |
+
readonly payload?: unknown;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
|
| 99 |
+
|
| 100 |
+
export class GlobalEventsWs {
|
| 101 |
+
private readonly wsUrl: string;
|
| 102 |
+
private readonly token?: string;
|
| 103 |
+
private readonly handlers: GlobalEventsWsHandlers;
|
| 104 |
+
private readonly WsCtor: WsLikeCtor;
|
| 105 |
+
private readonly reconnectDelayMs: number;
|
| 106 |
+
|
| 107 |
+
private ws: WsLike | undefined;
|
| 108 |
+
private manualClose = false;
|
| 109 |
+
private reconnectAttempt = 0;
|
| 110 |
+
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
| 111 |
+
|
| 112 |
+
constructor(opts: GlobalEventsWsOptions) {
|
| 113 |
+
this.wsUrl = toWsUrl(opts.url);
|
| 114 |
+
this.token = opts.token;
|
| 115 |
+
this.handlers = opts.handlers;
|
| 116 |
+
const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined);
|
| 117 |
+
if (ctor === undefined) {
|
| 118 |
+
throw new Error('no WebSocket implementation available; pass WebSocketImpl');
|
| 119 |
+
}
|
| 120 |
+
this.WsCtor = ctor;
|
| 121 |
+
this.reconnectDelayMs = opts.reconnectDelayMs ?? 500;
|
| 122 |
+
this.connect();
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/** Tear the socket down permanently. */
|
| 126 |
+
close(): void {
|
| 127 |
+
this.manualClose = true;
|
| 128 |
+
if (this.reconnectTimer !== undefined) {
|
| 129 |
+
clearTimeout(this.reconnectTimer);
|
| 130 |
+
this.reconnectTimer = undefined;
|
| 131 |
+
}
|
| 132 |
+
const ws = this.ws;
|
| 133 |
+
this.ws = undefined;
|
| 134 |
+
ws?.close();
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
private connect(): void {
|
| 138 |
+
const protocols =
|
| 139 |
+
this.token !== undefined && this.token.length > 0
|
| 140 |
+
? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`]
|
| 141 |
+
: undefined;
|
| 142 |
+
let ws: WsLike;
|
| 143 |
+
try {
|
| 144 |
+
ws = new this.WsCtor(this.wsUrl, protocols);
|
| 145 |
+
} catch {
|
| 146 |
+
this.scheduleReconnect();
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
this.ws = ws;
|
| 150 |
+
ws.addEventListener('open', () => {
|
| 151 |
+
this.reconnectAttempt = 0;
|
| 152 |
+
this.send({
|
| 153 |
+
type: 'client_hello',
|
| 154 |
+
id: `kimi-inspect-global-${Date.now().toString(36)}`,
|
| 155 |
+
payload: { client_id: 'kimi-inspect', subscriptions: [] },
|
| 156 |
+
});
|
| 157 |
+
// Established (first connect and every reconnect alike): live facts may
|
| 158 |
+
// have been missed — the consumer re-seeds from REST.
|
| 159 |
+
this.handlers.onReconnected();
|
| 160 |
+
});
|
| 161 |
+
ws.addEventListener('message', (event: { data: unknown }) => {
|
| 162 |
+
this.onMessage(event.data);
|
| 163 |
+
});
|
| 164 |
+
ws.addEventListener('close', () => {
|
| 165 |
+
// Stale socket (a manual close already cleared `this.ws`).
|
| 166 |
+
if (this.ws !== ws) return;
|
| 167 |
+
this.ws = undefined;
|
| 168 |
+
if (!this.manualClose) this.scheduleReconnect();
|
| 169 |
+
});
|
| 170 |
+
ws.addEventListener('error', () => {
|
| 171 |
+
// The 'close' event always follows 'error'; reconnect logic lives there.
|
| 172 |
+
});
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
private onMessage(raw: unknown): void {
|
| 176 |
+
let frame: ServerFrame;
|
| 177 |
+
try {
|
| 178 |
+
frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame;
|
| 179 |
+
} catch {
|
| 180 |
+
return;
|
| 181 |
+
}
|
| 182 |
+
const sessionId = frame.session_id;
|
| 183 |
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
| 184 |
+
switch (frame.type) {
|
| 185 |
+
case 'event.session.work_changed': {
|
| 186 |
+
const facts = parseWorkFacts(frame.payload);
|
| 187 |
+
if (facts !== undefined) this.handlers.onWorkChanged(sessionId, facts);
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
case 'event.session.created': {
|
| 191 |
+
this.handlers.onSessionCreated(sessionId);
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
case 'event.session.archived': {
|
| 195 |
+
const payload = frame.payload as { sessionId?: unknown } | undefined;
|
| 196 |
+
const archivedId = payload?.sessionId;
|
| 197 |
+
if (typeof archivedId === 'string' && archivedId !== '') {
|
| 198 |
+
this.handlers.onSessionArchived?.(archivedId);
|
| 199 |
+
}
|
| 200 |
+
return;
|
| 201 |
+
}
|
| 202 |
+
case 'event.session.deleted': {
|
| 203 |
+
const payload = frame.payload as { sessionId?: unknown } | undefined;
|
| 204 |
+
const deletedId = payload?.sessionId;
|
| 205 |
+
if (typeof deletedId === 'string' && deletedId !== '') {
|
| 206 |
+
this.handlers.onSessionDeleted?.(deletedId);
|
| 207 |
+
}
|
| 208 |
+
return;
|
| 209 |
+
}
|
| 210 |
+
case 'event.workspace.created':
|
| 211 |
+
case 'event.workspace.updated':
|
| 212 |
+
case 'event.workspace.deleted': {
|
| 213 |
+
this.handlers.onWorkspaceChanged?.();
|
| 214 |
+
return;
|
| 215 |
+
}
|
| 216 |
+
case 'session.meta.updated': {
|
| 217 |
+
this.handlers.onMetaUpdated(sessionId);
|
| 218 |
+
return;
|
| 219 |
+
}
|
| 220 |
+
case 'event.di.unit_changed': {
|
| 221 |
+
const payload = parseDiUnitChangedPayload(frame.payload);
|
| 222 |
+
if (payload !== undefined) this.handlers.onDiUnitChanged?.(payload);
|
| 223 |
+
return;
|
| 224 |
+
}
|
| 225 |
+
case 'ping': {
|
| 226 |
+
const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce;
|
| 227 |
+
this.send({ type: 'pong', payload: { nonce } });
|
| 228 |
+
return;
|
| 229 |
+
}
|
| 230 |
+
default:
|
| 231 |
+
return;
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
private scheduleReconnect(): void {
|
| 236 |
+
if (this.manualClose) return;
|
| 237 |
+
this.reconnectAttempt += 1;
|
| 238 |
+
const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000);
|
| 239 |
+
this.reconnectTimer = setTimeout(() => {
|
| 240 |
+
this.reconnectTimer = undefined;
|
| 241 |
+
this.connect();
|
| 242 |
+
}, delay);
|
| 243 |
+
this.reconnectTimer.unref?.();
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
private send(frame: Record<string, unknown>): void {
|
| 247 |
+
const ws = this.ws;
|
| 248 |
+
if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return;
|
| 249 |
+
try {
|
| 250 |
+
ws.send(JSON.stringify(frame));
|
| 251 |
+
} catch {
|
| 252 |
+
// best-effort; the close handler handles teardown
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function parseWorkFacts(payload: unknown): SessionWorkFacts | undefined {
|
| 258 |
+
if (typeof payload !== 'object' || payload === null) return undefined;
|
| 259 |
+
const p = payload as Record<string, unknown>;
|
| 260 |
+
if (typeof p['busy'] !== 'boolean') return undefined;
|
| 261 |
+
const pending = p['pending_interaction'];
|
| 262 |
+
const reason = p['last_turn_reason'];
|
| 263 |
+
return {
|
| 264 |
+
busy: p['busy'],
|
| 265 |
+
mainTurnActive: p['main_turn_active'] === true,
|
| 266 |
+
pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none',
|
| 267 |
+
lastTurnReason:
|
| 268 |
+
reason === 'completed' || reason === 'cancelled' || reason === 'failed' ? reason : undefined,
|
| 269 |
+
};
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
const DI_UNIT_STATES: ReadonlySet<string> = new Set([
|
| 273 |
+
'Pending',
|
| 274 |
+
'Activating',
|
| 275 |
+
'Active',
|
| 276 |
+
'Unloading',
|
| 277 |
+
'Failed',
|
| 278 |
+
]);
|
| 279 |
+
|
| 280 |
+
function parseDiUnitChangedPayload(payload: unknown): DiUnitChangedPayload | undefined {
|
| 281 |
+
if (typeof payload !== 'object' || payload === null) return undefined;
|
| 282 |
+
const p = payload as Record<string, unknown>;
|
| 283 |
+
if (typeof p['scope'] !== 'string' || typeof p['token'] !== 'string') return undefined;
|
| 284 |
+
const state = p['state'];
|
| 285 |
+
if (typeof state !== 'string' || !DI_UNIT_STATES.has(state)) return undefined;
|
| 286 |
+
return {
|
| 287 |
+
scope: p['scope'],
|
| 288 |
+
token: p['token'],
|
| 289 |
+
state: state as DiUnitState,
|
| 290 |
+
error: typeof p['error'] === 'string' ? p['error'] : undefined,
|
| 291 |
+
};
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/** Derive the `/api/v1/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */
|
| 295 |
+
function toWsUrl(base: string): string {
|
| 296 |
+
const url = new URL(base);
|
| 297 |
+
if (url.protocol === 'http:') url.protocol = 'ws:';
|
| 298 |
+
else if (url.protocol === 'https:') url.protocol = 'wss:';
|
| 299 |
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
| 300 |
+
throw new Error(`unsupported URL scheme for WS transport: ${base}`);
|
| 301 |
+
}
|
| 302 |
+
if (!url.pathname.endsWith('/api/v1/ws')) {
|
| 303 |
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/ws`;
|
| 304 |
+
}
|
| 305 |
+
url.search = '';
|
| 306 |
+
url.hash = '';
|
| 307 |
+
return url.toString();
|
| 308 |
+
}
|
apps/kimi-inspect/src/audit/audit.test.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Audit-layer tests: the trail recorder, the structural diff, serialization,
|
| 3 |
+
* and tail-preserving truncation used by the chat view's audit panel.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import type { StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol';
|
| 7 |
+
import { describe, expect, it } from 'vitest';
|
| 8 |
+
|
| 9 |
+
import { EMPTY_CHAT_STATE, type ChatState } from '../transcript/store';
|
| 10 |
+
import { diffValue, type DiffNode } from './diff';
|
| 11 |
+
import { serializeState } from './serialize';
|
| 12 |
+
import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail';
|
| 13 |
+
import { tailTrunc } from './truncate';
|
| 14 |
+
|
| 15 |
+
const T0 = Date.parse('2026-01-01T00:00:00.000Z');
|
| 16 |
+
let tick = 0;
|
| 17 |
+
|
| 18 |
+
function ts(): number {
|
| 19 |
+
tick += 1;
|
| 20 |
+
return T0 + tick * 1000;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function turnMsg(n: number, status: 'running' | 'completed' = 'completed'): TurnMessage {
|
| 24 |
+
return {
|
| 25 |
+
type: 'turn',
|
| 26 |
+
session_id: 's1',
|
| 27 |
+
agent_id: 'main',
|
| 28 |
+
timestamp: ts(),
|
| 29 |
+
turn_id: `t${n}`,
|
| 30 |
+
ordinal: n,
|
| 31 |
+
status,
|
| 32 |
+
origin: { kind: 'user' },
|
| 33 |
+
};
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function stepMsg(stepId: string, status: 'running' | 'completed'): StepMessage {
|
| 37 |
+
return {
|
| 38 |
+
type: 'step',
|
| 39 |
+
session_id: 's1',
|
| 40 |
+
agent_id: 'main',
|
| 41 |
+
timestamp: ts(),
|
| 42 |
+
step_id: stepId,
|
| 43 |
+
turn_id: stepId.split('.')[0] ?? 't1',
|
| 44 |
+
ordinal: Number(stepId.split('.')[1] ?? '1'),
|
| 45 |
+
status,
|
| 46 |
+
};
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function stateWithTimeline(items: readonly (TurnMessage | StepMessage)[]): ChatState {
|
| 50 |
+
return {
|
| 51 |
+
...EMPTY_CHAT_STATE,
|
| 52 |
+
entries: items.map((message) => ({
|
| 53 |
+
key: message.type === 'turn' ? `turn:${message.turn_id}` : `step:${message.step_id}`,
|
| 54 |
+
message,
|
| 55 |
+
})),
|
| 56 |
+
};
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// ---------------------------------------------------------------- diff
|
| 60 |
+
|
| 61 |
+
describe('diffValue', () => {
|
| 62 |
+
it('collapses reference-equal subtrees to unchanged without children', () => {
|
| 63 |
+
const shared = { a: 1, b: { c: 'x' } };
|
| 64 |
+
const node = diffValue({ v: shared }, { v: shared });
|
| 65 |
+
expect(node.status).toBe('unchanged');
|
| 66 |
+
expect(node.children?.get('v')?.children).toBeUndefined();
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
it('marks added, removed, and modified object keys', () => {
|
| 70 |
+
const node = diffValue(
|
| 71 |
+
{ keep: 1, gone: 'x', changed: 'a' },
|
| 72 |
+
{ keep: 1, fresh: true, changed: 'b' },
|
| 73 |
+
);
|
| 74 |
+
expect(node.status).toBe('modified');
|
| 75 |
+
expect(node.children?.get('keep')?.status).toBe('unchanged');
|
| 76 |
+
expect(node.children?.get('fresh')?.status).toBe('added');
|
| 77 |
+
expect(node.children?.get('gone')).toMatchObject({ status: 'removed', prev: 'x' });
|
| 78 |
+
expect(node.children?.get('changed')).toMatchObject({
|
| 79 |
+
status: 'modified',
|
| 80 |
+
prev: 'a',
|
| 81 |
+
value: 'b',
|
| 82 |
+
});
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
it('matches entity arrays by id instead of index', () => {
|
| 86 |
+
const t1 = turnMsg(1);
|
| 87 |
+
const t2 = turnMsg(2);
|
| 88 |
+
const prev = [t1, t2];
|
| 89 |
+
const next = [t1, { ...t2, status: 'running' as const }, turnMsg(3)];
|
| 90 |
+
const node = diffValue(prev, next);
|
| 91 |
+
expect(node.children?.get('t1')?.status).toBe('unchanged');
|
| 92 |
+
expect(node.children?.get('t2')?.status).toBe('modified');
|
| 93 |
+
expect(node.children?.get('t2')?.children?.get('status')).toMatchObject({
|
| 94 |
+
status: 'modified',
|
| 95 |
+
prev: 'completed',
|
| 96 |
+
value: 'running',
|
| 97 |
+
});
|
| 98 |
+
expect(node.children?.get('t3')?.status).toBe('added');
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
it('keys steps by step_id (not their shared turn_id) so siblings never collide', () => {
|
| 102 |
+
const done = stepMsg('t1.1', 'completed');
|
| 103 |
+
const node = diffValue(
|
| 104 |
+
[done, stepMsg('t1.2', 'completed')],
|
| 105 |
+
[done, stepMsg('t1.2', 'running')],
|
| 106 |
+
);
|
| 107 |
+
expect([...(node.children?.keys() ?? [])]).toEqual(['t1.1', 't1.2']);
|
| 108 |
+
expect(node.children?.get('t1.1')?.status).toBe('unchanged');
|
| 109 |
+
expect(node.children?.get('t1.2')?.status).toBe('modified');
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
it('marks removed array elements by id', () => {
|
| 113 |
+
const t2 = turnMsg(2);
|
| 114 |
+
const node = diffValue([turnMsg(1), t2], [t2]);
|
| 115 |
+
expect(node.children?.get('t1')).toMatchObject({ status: 'removed' });
|
| 116 |
+
expect(node.children?.get('t2')?.status).toBe('unchanged');
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
it('marks whole-subtree adds/removes without descending', () => {
|
| 120 |
+
const added = diffValue(undefined, { nested: { deep: 1 } });
|
| 121 |
+
expect(added.status).toBe('added');
|
| 122 |
+
expect(added.children).toBeUndefined();
|
| 123 |
+
const removed = diffValue({ nested: 1 }, undefined);
|
| 124 |
+
expect(removed.status).toBe('removed');
|
| 125 |
+
expect(removed.children).toBeUndefined();
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
it('treats type changes as leaf modifications', () => {
|
| 129 |
+
expect(diffValue('1', 1).status).toBe('modified');
|
| 130 |
+
expect(diffValue(null, {}).status).toBe('modified');
|
| 131 |
+
expect(diffValue([1], { 0: 1 }).status).toBe('modified');
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
it('diffs two serialized states with session.state changes visible', () => {
|
| 135 |
+
const base = stateWithTimeline([turnMsg(1)]);
|
| 136 |
+
const prev = serializeState(base);
|
| 137 |
+
const nextState: ChatState = {
|
| 138 |
+
...base,
|
| 139 |
+
sessionState: {
|
| 140 |
+
type: 'session.state',
|
| 141 |
+
session_id: 's1',
|
| 142 |
+
timestamp: ts(),
|
| 143 |
+
status: 'running',
|
| 144 |
+
goal: { objective: 'ship it', status: 'active' },
|
| 145 |
+
modes: { plan: { review_path: '/tmp/plan.md' } },
|
| 146 |
+
},
|
| 147 |
+
};
|
| 148 |
+
const node: DiffNode = diffValue(prev, serializeState(nextState));
|
| 149 |
+
expect(node.children?.get('timeline')?.status).toBe('unchanged');
|
| 150 |
+
const sessionState = node.children?.get('sessionState');
|
| 151 |
+
expect(sessionState?.status).toBe('added');
|
| 152 |
+
expect(sessionState?.children).toBeUndefined();
|
| 153 |
+
});
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
// ---------------------------------------------------------------- serialize
|
| 157 |
+
|
| 158 |
+
describe('serializeState', () => {
|
| 159 |
+
it('turns maps into sorted plain objects and flattens the timeline', () => {
|
| 160 |
+
const state: ChatState = {
|
| 161 |
+
...EMPTY_CHAT_STATE,
|
| 162 |
+
entries: stateWithTimeline([turnMsg(1)]).entries,
|
| 163 |
+
tasks: new Map([
|
| 164 |
+
[
|
| 165 |
+
'b-task',
|
| 166 |
+
{
|
| 167 |
+
type: 'task',
|
| 168 |
+
session_id: 's1',
|
| 169 |
+
agent_id: 'main',
|
| 170 |
+
timestamp: ts(),
|
| 171 |
+
task_id: 'b-task',
|
| 172 |
+
kind: 'shell',
|
| 173 |
+
status: 'running',
|
| 174 |
+
detached: false,
|
| 175 |
+
output_tail: '',
|
| 176 |
+
},
|
| 177 |
+
],
|
| 178 |
+
[
|
| 179 |
+
'a-task',
|
| 180 |
+
{
|
| 181 |
+
type: 'task',
|
| 182 |
+
session_id: 's1',
|
| 183 |
+
agent_id: 'main',
|
| 184 |
+
timestamp: ts(),
|
| 185 |
+
task_id: 'a-task',
|
| 186 |
+
kind: 'tool',
|
| 187 |
+
status: 'completed',
|
| 188 |
+
detached: false,
|
| 189 |
+
output_tail: '',
|
| 190 |
+
},
|
| 191 |
+
],
|
| 192 |
+
]),
|
| 193 |
+
};
|
| 194 |
+
const out = serializeState(state);
|
| 195 |
+
expect(Object.keys(out.tasks)).toEqual(['a-task', 'b-task']);
|
| 196 |
+
expect(out.timeline.map((m) => (m.type === 'turn' ? m.turn_id : ''))).toEqual(['t1']);
|
| 197 |
+
expect(out.hasMoreOlder).toBe(false);
|
| 198 |
+
});
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
// ---------------------------------------------------------------- truncate
|
| 202 |
+
|
| 203 |
+
describe('tailTrunc', () => {
|
| 204 |
+
it('returns short strings unchanged', () => {
|
| 205 |
+
expect(tailTrunc('hello')).toBe('hello');
|
| 206 |
+
expect(tailTrunc('x'.repeat(500))).toBe('x'.repeat(500));
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
it('keeps the tail of long strings and reports the total length', () => {
|
| 210 |
+
const value = 'head-padding'.repeat(100) + 'THE-TAIL';
|
| 211 |
+
const out = tailTrunc(value, 50);
|
| 212 |
+
expect(out).toContain(`${value.length} chars total`);
|
| 213 |
+
expect(out.endsWith('THE-TAIL')).toBe(true);
|
| 214 |
+
expect(out).not.toContain('head-padding'.repeat(10));
|
| 215 |
+
});
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
// ---------------------------------------------------------------- trail
|
| 219 |
+
|
| 220 |
+
describe('AuditTrail', () => {
|
| 221 |
+
it('records entries with increasing indices, timestamps, and state references', () => {
|
| 222 |
+
const trail = new AuditTrail();
|
| 223 |
+
const s1 = stateWithTimeline([turnMsg(1)]);
|
| 224 |
+
const s2 = stateWithTimeline([turnMsg(1), turnMsg(2)]);
|
| 225 |
+
trail.recordRest({ pageSize: 500 }, 'replace', 1, { turn_id: 't1', step_id: 't1.1' }, s1);
|
| 226 |
+
trail.recordWs(turnMsg(2, 'running'), s2);
|
| 227 |
+
trail.recordEvent('prompt', 'hello', s2);
|
| 228 |
+
|
| 229 |
+
const entries = trail.getEntries();
|
| 230 |
+
expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ws', 'event']);
|
| 231 |
+
expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2]);
|
| 232 |
+
expect(entries[0]!.state).toBe(s1);
|
| 233 |
+
expect(entries[1]!.state).toBe(s2);
|
| 234 |
+
expect(entries[0]).toMatchObject({ mode: 'replace', messageCount: 1 });
|
| 235 |
+
expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' });
|
| 236 |
+
expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe(
|
| 237 |
+
true,
|
| 238 |
+
);
|
| 239 |
+
expect(entries.every((entry) => entry.summary.length > 0)).toBe(true);
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
it('notifies subscribers on each record', () => {
|
| 243 |
+
const trail = new AuditTrail();
|
| 244 |
+
let notified = 0;
|
| 245 |
+
const unsubscribe = trail.subscribe(() => {
|
| 246 |
+
notified += 1;
|
| 247 |
+
});
|
| 248 |
+
trail.recordEvent('cancel', undefined, EMPTY_CHAT_STATE);
|
| 249 |
+
trail.recordEvent('ack', undefined, EMPTY_CHAT_STATE);
|
| 250 |
+
expect(notified).toBe(2);
|
| 251 |
+
unsubscribe();
|
| 252 |
+
trail.recordEvent('reconnect', undefined, EMPTY_CHAT_STATE);
|
| 253 |
+
expect(notified).toBe(2);
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
it('drops the oldest entries beyond the cap while indices keep increasing', () => {
|
| 257 |
+
const trail = new AuditTrail();
|
| 258 |
+
for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) {
|
| 259 |
+
trail.recordEvent('prompt', `p${i}`, EMPTY_CHAT_STATE);
|
| 260 |
+
}
|
| 261 |
+
const entries = trail.getEntries();
|
| 262 |
+
expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES);
|
| 263 |
+
expect(entries[0]!.index).toBe(10);
|
| 264 |
+
expect(entries.at(-1)!.index).toBe(AUDIT_TRAIL_MAX_ENTRIES + 9);
|
| 265 |
+
});
|
| 266 |
+
});
|
apps/kimi-inspect/src/audit/diff.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Structural diff over serialized `ChatState` values (see `serialize.ts`).
|
| 3 |
+
*
|
| 4 |
+
* The audit panel diffs two adjacent, immutable states. Because the store
|
| 5 |
+
* is copy-on-write, untouched subtrees share references — the reference
|
| 6 |
+
* equality fast path below collapses them to `unchanged` without walking.
|
| 7 |
+
*
|
| 8 |
+
* Arrays of protocol entities are matched by their id field (turn_id,
|
| 9 |
+
* step_id, message_id, tool_call_id, …) rather than by index, so an upsert
|
| 10 |
+
* in the middle of the timeline does not turn into a cascade of spurious
|
| 11 |
+
* modifications.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified';
|
| 15 |
+
|
| 16 |
+
export interface DiffNode {
|
| 17 |
+
readonly status: DiffStatus;
|
| 18 |
+
/** Current value (`undefined` when removed). */
|
| 19 |
+
readonly value: unknown;
|
| 20 |
+
/** Previous value (`undefined` when added). */
|
| 21 |
+
readonly prev: unknown;
|
| 22 |
+
/**
|
| 23 |
+
* Object/array children — key is the object key, the entity id, or
|
| 24 |
+
* `#<index>` for plain arrays. Absent on leaves and on whole-subtree
|
| 25 |
+
* added/removed nodes (the renderer colors the subtree as one block).
|
| 26 |
+
*/
|
| 27 |
+
readonly children?: ReadonlyMap<string, DiffNode>;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Id fields checked in priority order — MOST SPECIFIC FIRST. An interaction
|
| 32 |
+
* carries both `interaction_id` and `tool_call_id`, a tool call can carry
|
| 33 |
+
* `task_id` / `todo_id` alongside its `tool_call_id`, and every timeline
|
| 34 |
+
* entity carries `turn_id`; matching the wrong one mislabels the node and,
|
| 35 |
+
* worse, collides siblings in the children map (two tool calls of one task
|
| 36 |
+
* both keyed by that task id).
|
| 37 |
+
*/
|
| 38 |
+
const ID_FIELDS = [
|
| 39 |
+
'message_id',
|
| 40 |
+
'interaction_id',
|
| 41 |
+
'tool_call_id',
|
| 42 |
+
'task_id',
|
| 43 |
+
'todo_id',
|
| 44 |
+
'system_id',
|
| 45 |
+
'step_id',
|
| 46 |
+
'turn_id',
|
| 47 |
+
] as const;
|
| 48 |
+
|
| 49 |
+
function elementId(element: unknown): string | undefined {
|
| 50 |
+
if (typeof element !== 'object' || element === null) return undefined;
|
| 51 |
+
for (const field of ID_FIELDS) {
|
| 52 |
+
const value = (element as Record<string, unknown>)[field];
|
| 53 |
+
if (typeof value === 'string') return value;
|
| 54 |
+
}
|
| 55 |
+
return undefined;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/** Public for the audit UI: same id-priority keying used to match array elements. */
|
| 59 |
+
export { elementId };
|
| 60 |
+
|
| 61 |
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
| 62 |
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function containerStatus(children: ReadonlyMap<string, DiffNode>): DiffStatus {
|
| 66 |
+
for (const child of children.values()) {
|
| 67 |
+
if (child.status !== 'unchanged') return 'modified';
|
| 68 |
+
}
|
| 69 |
+
return 'unchanged';
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function diffObjects(prev: Record<string, unknown>, next: Record<string, unknown>): DiffNode {
|
| 73 |
+
const children = new Map<string, DiffNode>();
|
| 74 |
+
for (const key of Object.keys(next)) {
|
| 75 |
+
children.set(key, diffValue(prev[key], next[key]));
|
| 76 |
+
}
|
| 77 |
+
for (const key of Object.keys(prev)) {
|
| 78 |
+
if (!(key in next)) {
|
| 79 |
+
children.set(key, { status: 'removed', value: undefined, prev: prev[key] });
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
return { status: containerStatus(children), value: next, prev, children };
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function diffArrays(prev: readonly unknown[], next: readonly unknown[]): DiffNode {
|
| 86 |
+
const children = new Map<string, DiffNode>();
|
| 87 |
+
const keyed =
|
| 88 |
+
prev.every((el) => elementId(el) !== undefined) &&
|
| 89 |
+
next.every((el) => elementId(el) !== undefined);
|
| 90 |
+
|
| 91 |
+
if (keyed) {
|
| 92 |
+
const prevById = new Map<string, unknown>();
|
| 93 |
+
for (const el of prev) prevById.set(elementId(el) as string, el);
|
| 94 |
+
const nextIds = new Set<string>();
|
| 95 |
+
for (const el of next) {
|
| 96 |
+
const id = elementId(el) as string;
|
| 97 |
+
nextIds.add(id);
|
| 98 |
+
children.set(id, diffValue(prevById.get(id), el));
|
| 99 |
+
}
|
| 100 |
+
for (const el of prev) {
|
| 101 |
+
const id = elementId(el) as string;
|
| 102 |
+
if (!nextIds.has(id)) children.set(id, { status: 'removed', value: undefined, prev: el });
|
| 103 |
+
}
|
| 104 |
+
} else {
|
| 105 |
+
for (let i = 0; i < next.length; i += 1) {
|
| 106 |
+
children.set(`#${i}`, diffValue(prev[i], next[i]));
|
| 107 |
+
}
|
| 108 |
+
for (let i = next.length; i < prev.length; i += 1) {
|
| 109 |
+
children.set(`#${i}`, { status: 'removed', value: undefined, prev: prev[i] });
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
return { status: containerStatus(children), value: next, prev, children };
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export function diffValue(prev: unknown, next: unknown): DiffNode {
|
| 116 |
+
if (prev === next) return { status: 'unchanged', value: next, prev };
|
| 117 |
+
if (prev === undefined) return { status: 'added', value: next, prev: undefined };
|
| 118 |
+
if (next === undefined) return { status: 'removed', value: undefined, prev };
|
| 119 |
+
if (isPlainObject(prev) && isPlainObject(next)) return diffObjects(prev, next);
|
| 120 |
+
if (Array.isArray(prev) && Array.isArray(next)) return diffArrays(prev, next);
|
| 121 |
+
return { status: 'modified', value: next, prev };
|
| 122 |
+
}
|
apps/kimi-inspect/src/audit/serialize.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Serialize a `ChatState` into a plain, JSON-shaped object for the audit
|
| 3 |
+
* panel's state tree and structural diff. Maps become key-sorted plain
|
| 4 |
+
* objects (stable display order); everything else is passed through by
|
| 5 |
+
* reference (state is immutable, so sharing is safe and keeps the
|
| 6 |
+
* reference-equality fast path in `diffValue` useful).
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import type {
|
| 10 |
+
InteractionMessage,
|
| 11 |
+
SessionStateMessage,
|
| 12 |
+
TaskMessage,
|
| 13 |
+
TodoMessage,
|
| 14 |
+
} from '@moonshot-ai/kap-server/protocol';
|
| 15 |
+
|
| 16 |
+
import type { ChatState, TimelineMessage } from '../transcript/store';
|
| 17 |
+
|
| 18 |
+
/** Plain-object view of a `ChatState` (Maps unwrapped). */
|
| 19 |
+
export interface SerializedChatState {
|
| 20 |
+
readonly timeline: readonly TimelineMessage[];
|
| 21 |
+
readonly interactions: Record<string, InteractionMessage>;
|
| 22 |
+
readonly tasks: Record<string, TaskMessage>;
|
| 23 |
+
readonly todos: Record<string, TodoMessage>;
|
| 24 |
+
readonly sessionState: SessionStateMessage | undefined;
|
| 25 |
+
readonly hasMoreOlder: boolean;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function mapToSortedObject<V>(map: ReadonlyMap<string, V>): Record<string, V> {
|
| 29 |
+
const out: Record<string, V> = {};
|
| 30 |
+
for (const key of [...map.keys()].toSorted()) out[key] = map.get(key) as V;
|
| 31 |
+
return out;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function serializeState(state: ChatState): SerializedChatState {
|
| 35 |
+
return {
|
| 36 |
+
timeline: state.entries.map((entry) => entry.message),
|
| 37 |
+
interactions: mapToSortedObject(state.interactions),
|
| 38 |
+
tasks: mapToSortedObject(state.tasks),
|
| 39 |
+
todos: mapToSortedObject(state.todos),
|
| 40 |
+
sessionState: state.sessionState,
|
| 41 |
+
hasMoreOlder: state.hasMoreOlder,
|
| 42 |
+
};
|
| 43 |
+
}
|
apps/kimi-inspect/src/audit/trail.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Audit trail for the chat view's message-protocol channel.
|
| 3 |
+
*
|
| 4 |
+
* A pure observer: the chat pipeline (REST history loads, WS messages, user
|
| 5 |
+
* actions) calls the `record*` methods AFTER applying each step to the real
|
| 6 |
+
* `ChatStore`, passing the resulting immutable `ChatState` reference.
|
| 7 |
+
* Replaying the trail is therefore free — every entry already holds the
|
| 8 |
+
* exact state the store had at that point, ready for the timeline slider
|
| 9 |
+
* and the structural diff.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import type { ServerMessage } from '@moonshot-ai/kap-server/protocol';
|
| 13 |
+
|
| 14 |
+
import type { ChatState } from '../transcript/store';
|
| 15 |
+
|
| 16 |
+
export const AUDIT_TRAIL_MAX_ENTRIES = 5000;
|
| 17 |
+
|
| 18 |
+
interface AuditEntryBase {
|
| 19 |
+
/** Position in the trail (stable even when old entries are dropped). */
|
| 20 |
+
readonly index: number;
|
| 21 |
+
/** Local record time (ISO). */
|
| 22 |
+
readonly at: string;
|
| 23 |
+
/** Store state right after this entry was applied (immutable reference). */
|
| 24 |
+
readonly state: ChatState;
|
| 25 |
+
/** One-line summary for the timeline list. */
|
| 26 |
+
readonly summary: string;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface RestAuditEntry extends AuditEntryBase {
|
| 30 |
+
readonly kind: 'rest';
|
| 31 |
+
readonly request: {
|
| 32 |
+
readonly beforeTurn?: string | undefined;
|
| 33 |
+
readonly afterStep?: string | undefined;
|
| 34 |
+
readonly pageSize: number;
|
| 35 |
+
};
|
| 36 |
+
/** replace = newest page (initial/refresh); prepend = older page; tail = after_step catch-up. */
|
| 37 |
+
readonly mode: 'replace' | 'prepend' | 'tail';
|
| 38 |
+
readonly messageCount: number;
|
| 39 |
+
readonly inFlight?: { turn_id: string; step_id: string } | undefined;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export interface WsAuditEntry extends AuditEntryBase {
|
| 43 |
+
readonly kind: 'ws';
|
| 44 |
+
/** The raw server message as applied to the store (entity, delta, or state). */
|
| 45 |
+
readonly message: ServerMessage;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export interface EventAuditEntry extends AuditEntryBase {
|
| 49 |
+
readonly kind: 'event';
|
| 50 |
+
readonly event:
|
| 51 |
+
| 'ack'
|
| 52 |
+
| 'ack-error'
|
| 53 |
+
| 'reconnect'
|
| 54 |
+
| 'catchup-refresh'
|
| 55 |
+
| 'protocol-error'
|
| 56 |
+
| 'invalid-frame'
|
| 57 |
+
| 'prompt'
|
| 58 |
+
| 'cancel'
|
| 59 |
+
| 'older-error';
|
| 60 |
+
readonly detail?: string | undefined;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export type AuditEntry = RestAuditEntry | WsAuditEntry | EventAuditEntry;
|
| 64 |
+
|
| 65 |
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
| 66 |
+
|
| 67 |
+
/** Entry payload accepted by `push` (index/at are filled in there). */
|
| 68 |
+
type AuditEntryInput = DistributiveOmit<AuditEntry, 'index' | 'at'>;
|
| 69 |
+
|
| 70 |
+
export class AuditTrail {
|
| 71 |
+
private entryList: AuditEntry[] = [];
|
| 72 |
+
private nextIndex = 0;
|
| 73 |
+
private readonly listeners = new Set<() => void>();
|
| 74 |
+
|
| 75 |
+
/** `useSyncExternalStore`-compatible subscribe. */
|
| 76 |
+
subscribe = (listener: () => void): (() => void) => {
|
| 77 |
+
this.listeners.add(listener);
|
| 78 |
+
return () => {
|
| 79 |
+
this.listeners.delete(listener);
|
| 80 |
+
};
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
getEntries(): readonly AuditEntry[] {
|
| 84 |
+
return this.entryList;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
recordRest(
|
| 88 |
+
request: RestAuditEntry['request'],
|
| 89 |
+
mode: RestAuditEntry['mode'],
|
| 90 |
+
messageCount: number,
|
| 91 |
+
inFlight: RestAuditEntry['inFlight'],
|
| 92 |
+
state: ChatState,
|
| 93 |
+
): void {
|
| 94 |
+
const cursor =
|
| 95 |
+
request.beforeTurn !== undefined
|
| 96 |
+
? `?before_turn=${request.beforeTurn}`
|
| 97 |
+
: request.afterStep !== undefined
|
| 98 |
+
? `?after_step=${request.afterStep}`
|
| 99 |
+
: '';
|
| 100 |
+
const flight = inFlight !== undefined ? ` (in_flight ${inFlight.step_id})` : '';
|
| 101 |
+
this.push({
|
| 102 |
+
kind: 'rest',
|
| 103 |
+
request,
|
| 104 |
+
mode,
|
| 105 |
+
messageCount,
|
| 106 |
+
inFlight,
|
| 107 |
+
state,
|
| 108 |
+
summary: `GET history${cursor} → ${messageCount} messages (${mode})${flight}`,
|
| 109 |
+
});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
recordWs(message: ServerMessage, state: ChatState): void {
|
| 113 |
+
this.push({
|
| 114 |
+
kind: 'ws',
|
| 115 |
+
message,
|
| 116 |
+
state,
|
| 117 |
+
summary: summarizeMessage(message),
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
recordEvent(
|
| 122 |
+
event: EventAuditEntry['event'],
|
| 123 |
+
detail: string | undefined,
|
| 124 |
+
state: ChatState,
|
| 125 |
+
): void {
|
| 126 |
+
const label =
|
| 127 |
+
event === 'ack'
|
| 128 |
+
? 'subscribe ack → after_step catch-up'
|
| 129 |
+
: event === 'ack-error'
|
| 130 |
+
? 'subscribe ack error'
|
| 131 |
+
: event === 'reconnect'
|
| 132 |
+
? 'socket dropped → reconnecting'
|
| 133 |
+
: event === 'catchup-refresh'
|
| 134 |
+
? 'catch-up anchor gone → full refresh'
|
| 135 |
+
: event === 'protocol-error'
|
| 136 |
+
? 'protocol error frame'
|
| 137 |
+
: event === 'invalid-frame'
|
| 138 |
+
? 'invalid frame (server bug)'
|
| 139 |
+
: event === 'prompt'
|
| 140 |
+
? 'prompt sent'
|
| 141 |
+
: event === 'cancel'
|
| 142 |
+
? 'cancel sent'
|
| 143 |
+
: 'older-page load failed';
|
| 144 |
+
this.push({
|
| 145 |
+
kind: 'event',
|
| 146 |
+
event,
|
| 147 |
+
detail,
|
| 148 |
+
state,
|
| 149 |
+
summary: detail !== undefined && detail !== '' ? `${label}: ${detail}` : label,
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
private push(entry: AuditEntryInput): void {
|
| 154 |
+
const full = { ...entry, index: this.nextIndex, at: new Date().toISOString() } as AuditEntry;
|
| 155 |
+
this.nextIndex += 1;
|
| 156 |
+
const kept =
|
| 157 |
+
this.entryList.length >= AUDIT_TRAIL_MAX_ENTRIES
|
| 158 |
+
? this.entryList.slice(this.entryList.length - AUDIT_TRAIL_MAX_ENTRIES + 1)
|
| 159 |
+
: this.entryList;
|
| 160 |
+
this.entryList = [...kept, full];
|
| 161 |
+
for (const listener of this.listeners) listener();
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function summarizeMessage(message: ServerMessage): string {
|
| 166 |
+
switch (message.type) {
|
| 167 |
+
case 'turn':
|
| 168 |
+
return `turn ${message.turn_id} (${message.status})`;
|
| 169 |
+
case 'step':
|
| 170 |
+
return `step ${message.step_id} (${message.status})`;
|
| 171 |
+
case 'user':
|
| 172 |
+
return `user ${message.message_id}`;
|
| 173 |
+
case 'assistant':
|
| 174 |
+
case 'thinking':
|
| 175 |
+
return `${message.type} ${message.message_id} (${message.status})`;
|
| 176 |
+
case 'assistant.delta':
|
| 177 |
+
case 'thinking.delta':
|
| 178 |
+
return `${message.type} ${message.message_id} +${message.text.length}ch`;
|
| 179 |
+
case 'tool_call':
|
| 180 |
+
return `tool_call ${message.name} ${message.tool_call_id} (${message.status})`;
|
| 181 |
+
case 'tool_call.delta':
|
| 182 |
+
return `tool_call.delta ${message.tool_call_id} +${message.input_text.length}ch`;
|
| 183 |
+
case 'tool.progress':
|
| 184 |
+
return `tool.progress ${message.tool_call_id} (${message.progress.kind})`;
|
| 185 |
+
case 'system':
|
| 186 |
+
return `system(${message.subtype}) ${message.system_id}`;
|
| 187 |
+
case 'interaction':
|
| 188 |
+
return `interaction ${message.interaction_id} (${message.kind}/${message.status})`;
|
| 189 |
+
case 'task':
|
| 190 |
+
return `task ${message.task_id} (${message.kind}/${message.status})`;
|
| 191 |
+
case 'todo':
|
| 192 |
+
return `todo ${message.todo_id} (${message.items.length} items)`;
|
| 193 |
+
case 'session.state':
|
| 194 |
+
return `session.state (${message.status})`;
|
| 195 |
+
default:
|
| 196 |
+
return message.type;
|
| 197 |
+
}
|
| 198 |
+
}
|
apps/kimi-inspect/src/audit/truncate.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tail-preserving string truncation for the audit panel: long values are
|
| 3 |
+
* rendered with their total length plus the LAST `keep` characters (the
|
| 4 |
+
* tail is where streaming text, tool output, and prompts carry the newest
|
| 5 |
+
* information). Rendering-only — the underlying state is never truncated,
|
| 6 |
+
* and no field is ever dropped.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
export const TRUNCATE_KEEP = 500;
|
| 10 |
+
|
| 11 |
+
export function tailTrunc(value: string, keep: number = TRUNCATE_KEEP): string {
|
| 12 |
+
if (value.length <= keep) return value;
|
| 13 |
+
return `… [${value.length} chars total, showing last ${keep}]\n${value.slice(-keep)}`;
|
| 14 |
+
}
|
apps/kimi-inspect/src/channel/channel.test.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Channel layer unit tests — `ProxyChannel` URL/envelope semantics,
|
| 3 |
+
* `makeProxy` routing, the HTTP-only `listen` failure, and the debug-surface
|
| 4 |
+
* probe (`/api/v1/debug` is the only RPC surface; there is no v2 fallback).
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
| 8 |
+
|
| 9 |
+
import type { Event, IChannel } from './channel';
|
| 10 |
+
import { probeDebugSurface } from './channels';
|
| 11 |
+
import { createInspectClient } from './client';
|
| 12 |
+
import { RPCError } from './errors';
|
| 13 |
+
import {
|
| 14 |
+
fetchAgentRuntimeBinding,
|
| 15 |
+
fetchSessionWorkspaceAssociation,
|
| 16 |
+
fetchWorkspaceSnapshot,
|
| 17 |
+
} from '../snapshots/api';
|
| 18 |
+
import { makeProxy } from './proxy';
|
| 19 |
+
import { ProxyChannel } from './proxyChannel';
|
| 20 |
+
|
| 21 |
+
const ok = (data: unknown) => ({ code: 0, msg: 'success', data, request_id: 'r1' });
|
| 22 |
+
|
| 23 |
+
function fakeFetch(envelope: unknown) {
|
| 24 |
+
const calls: { url: string; init?: RequestInit }[] = [];
|
| 25 |
+
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
|
| 26 |
+
calls.push({ url: String(url), init });
|
| 27 |
+
return { json: async () => envelope };
|
| 28 |
+
}) as unknown as typeof fetch;
|
| 29 |
+
return { calls, fetchImpl };
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
afterEach(() => {
|
| 33 |
+
vi.unstubAllGlobals();
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
describe('ProxyChannel.call', () => {
|
| 37 |
+
it('POSTs the command to the service base URL; no body and no header without args/token', async () => {
|
| 38 |
+
const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' }));
|
| 39 |
+
const channel = new ProxyChannel({
|
| 40 |
+
baseUrl: 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService',
|
| 41 |
+
fetch: fetchImpl,
|
| 42 |
+
});
|
| 43 |
+
const result = await channel.call('getModel', []);
|
| 44 |
+
expect(result).toEqual({ id: 's1' });
|
| 45 |
+
expect(calls).toHaveLength(1);
|
| 46 |
+
expect(calls[0]!.url).toBe(
|
| 47 |
+
'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService/getModel',
|
| 48 |
+
);
|
| 49 |
+
expect(calls[0]!.init?.method).toBe('POST');
|
| 50 |
+
expect(calls[0]!.init?.body).toBeUndefined();
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it('sends the complete argument array as the JSON body, plus the bearer token', async () => {
|
| 54 |
+
const { calls, fetchImpl } = fakeFetch(ok(null));
|
| 55 |
+
const channel = new ProxyChannel({
|
| 56 |
+
baseUrl: 'http://h:2/api/v1/debug/configService',
|
| 57 |
+
token: 'tok',
|
| 58 |
+
fetch: fetchImpl,
|
| 59 |
+
});
|
| 60 |
+
await channel.call('set', ['workspace', { theme: 'dark' }]);
|
| 61 |
+
expect(calls[0]!.init?.body).toBe(JSON.stringify(['workspace', { theme: 'dark' }]));
|
| 62 |
+
expect(calls[0]!.init?.headers).toEqual({
|
| 63 |
+
'content-type': 'application/json',
|
| 64 |
+
authorization: 'Bearer tok',
|
| 65 |
+
});
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
it('unwraps the envelope and throws RPCError on a non-zero code', async () => {
|
| 69 |
+
const { fetchImpl } = fakeFetch({
|
| 70 |
+
code: 40401,
|
| 71 |
+
msg: 'session not found',
|
| 72 |
+
data: null,
|
| 73 |
+
request_id: 'r2',
|
| 74 |
+
details: { id: 's9' },
|
| 75 |
+
});
|
| 76 |
+
const channel = new ProxyChannel({
|
| 77 |
+
baseUrl: 'http://h:3/api/v1/debug/sessionIndex',
|
| 78 |
+
fetch: fetchImpl,
|
| 79 |
+
});
|
| 80 |
+
const err: unknown = await channel.call('get', ['s9']).catch((error: unknown) => error);
|
| 81 |
+
expect(err).toBeInstanceOf(RPCError);
|
| 82 |
+
expect((err as RPCError).code).toBe(40401);
|
| 83 |
+
expect((err as RPCError).message).toBe('session not found');
|
| 84 |
+
expect((err as RPCError).details).toEqual({ id: 's9' });
|
| 85 |
+
});
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
describe('makeProxy', () => {
|
| 89 |
+
interface DemoService {
|
| 90 |
+
read(id: string, n: number): Promise<string>;
|
| 91 |
+
onDidChangeMetadata: Event<{ title: string }>;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
it('routes methods to call and onXxx members to listen', async () => {
|
| 95 |
+
const seen = { calls: [] as [string, unknown[]][], listens: [] as string[] };
|
| 96 |
+
const channel: IChannel = {
|
| 97 |
+
call: async <T>(command: string, args?: unknown[]): Promise<T> => {
|
| 98 |
+
seen.calls.push([command, args ?? []]);
|
| 99 |
+
return 'ret' as T;
|
| 100 |
+
},
|
| 101 |
+
listen: <T>(event: string): Event<T> => {
|
| 102 |
+
seen.listens.push(event);
|
| 103 |
+
return () => ({ dispose: () => {} });
|
| 104 |
+
},
|
| 105 |
+
};
|
| 106 |
+
const svc = makeProxy<DemoService>(channel);
|
| 107 |
+
await expect(svc.read('a', 1)).resolves.toBe('ret');
|
| 108 |
+
expect(seen.calls).toEqual([['read', ['a', 1]]]);
|
| 109 |
+
const d = svc.onDidChangeMetadata(() => {});
|
| 110 |
+
d.dispose();
|
| 111 |
+
expect(seen.listens).toEqual(['onDidChangeMetadata']);
|
| 112 |
+
});
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
describe('ProxyChannel.listen', () => {
|
| 116 |
+
it('throws: the debug surface is HTTP-only, there is no event transport', () => {
|
| 117 |
+
const channel = new ProxyChannel({
|
| 118 |
+
baseUrl: 'http://h:4/api/v1/debug/configService',
|
| 119 |
+
fetch: fakeFetch(ok(null)).fetchImpl,
|
| 120 |
+
});
|
| 121 |
+
expect(() => channel.listen('onDidChangeConfiguration')).toThrow(/events are not supported/);
|
| 122 |
+
});
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
describe('business snapshots', () => {
|
| 126 |
+
it('uses explicit workspace, session association, and agent binding routes', async () => {
|
| 127 |
+
const calls: string[] = [];
|
| 128 |
+
vi.stubGlobal('fetch', async (url: string | URL) => {
|
| 129 |
+
const value = String(url);
|
| 130 |
+
calls.push(value);
|
| 131 |
+
if (value.endsWith('/workspace/w%201/snapshot')) {
|
| 132 |
+
return { json: async () => ok({ metadata: { id: 'w 1' } }) };
|
| 133 |
+
}
|
| 134 |
+
if (value.endsWith('/session/s%201/association')) {
|
| 135 |
+
return { json: async () => ok({ sessionId: 's 1', workspaceId: 'w 1', cwd: '/work' }) };
|
| 136 |
+
}
|
| 137 |
+
return {
|
| 138 |
+
json: async () => ok({
|
| 139 |
+
binding: { workspaceId: 'w 1', runtimeId: 'remote' },
|
| 140 |
+
available: true,
|
| 141 |
+
runtime: { runtimeId: 'remote', generation: 'g2', status: 'ready', capabilities: ['process'] },
|
| 142 |
+
}),
|
| 143 |
+
};
|
| 144 |
+
});
|
| 145 |
+
const client = createInspectClient({ url: 'http://h:9', token: 'tok' });
|
| 146 |
+
|
| 147 |
+
await expect(fetchWorkspaceSnapshot(client, 'w 1')).resolves.toMatchObject({ metadata: { id: 'w 1' } });
|
| 148 |
+
await expect(fetchSessionWorkspaceAssociation(client, 's 1')).resolves.toMatchObject({ workspaceId: 'w 1' });
|
| 149 |
+
await expect(fetchAgentRuntimeBinding(client, 's 1', 'main')).resolves.toMatchObject({
|
| 150 |
+
binding: { runtimeId: 'remote' },
|
| 151 |
+
runtime: { generation: 'g2' },
|
| 152 |
+
});
|
| 153 |
+
expect(calls).toEqual([
|
| 154 |
+
'http://h:9/api/v1/debug/workspace/w%201/snapshot',
|
| 155 |
+
'http://h:9/api/v1/debug/session/s%201/association',
|
| 156 |
+
'http://h:9/api/v1/debug/session/s%201/agent/main/runtime-binding',
|
| 157 |
+
]);
|
| 158 |
+
});
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
describe('probeDebugSurface', () => {
|
| 162 |
+
function stubProbeFetch(impl: (url: string, init?: RequestInit) => unknown) {
|
| 163 |
+
const calls: { url: string; init?: RequestInit }[] = [];
|
| 164 |
+
vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
|
| 165 |
+
calls.push({ url: String(url), init });
|
| 166 |
+
return impl(String(url), init);
|
| 167 |
+
});
|
| 168 |
+
return calls;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
it('resolves when /api/v1/debug/channels answers a zero-code envelope (with bearer header)', async () => {
|
| 172 |
+
const calls = stubProbeFetch(() => ({ ok: true, json: async () => ({ code: 0 }) }));
|
| 173 |
+
await expect(
|
| 174 |
+
probeDebugSurface({ baseUrl: 'http://h:5/', token: 'tok' }),
|
| 175 |
+
).resolves.toBeUndefined();
|
| 176 |
+
expect(calls[0]!.url).toBe('http://h:5/api/v1/debug/channels');
|
| 177 |
+
expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' });
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
it('throws a --debug-endpoints hint when the surface is not mounted (HTTP 404)', async () => {
|
| 181 |
+
stubProbeFetch(() => ({ ok: false, status: 404 }));
|
| 182 |
+
await expect(probeDebugSurface({ baseUrl: 'http://h:6' })).rejects.toThrow(/--debug-endpoints/);
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
it('throws an unreachable-server error when fetch itself fails', async () => {
|
| 186 |
+
vi.stubGlobal('fetch', async () => {
|
| 187 |
+
throw new Error('ECONNREFUSED');
|
| 188 |
+
});
|
| 189 |
+
await expect(probeDebugSurface({ baseUrl: 'http://h:7' })).rejects.toThrow(/cannot reach/);
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
it('throws a token hint when the envelope carries a non-zero code', async () => {
|
| 193 |
+
stubProbeFetch(() => ({
|
| 194 |
+
ok: true,
|
| 195 |
+
json: async () => ({ code: 40101, msg: 'unauthorized' }),
|
| 196 |
+
}));
|
| 197 |
+
await expect(probeDebugSurface({ baseUrl: 'http://h:8' })).rejects.toThrow(/bearer token/);
|
| 198 |
+
});
|
| 199 |
+
});
|
apps/kimi-inspect/src/channel/channel.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Transport-agnostic channel contract for the debug-RPC client — the
|
| 3 |
+
* old-klient / VS Code `ProxyChannel` model: the channel is bound to one
|
| 4 |
+
* Service (the URL carries the scope + the Service's decorator id) and
|
| 5 |
+
* `command` is the method name, invoked by reflection on the server.
|
| 6 |
+
*
|
| 7 |
+
* `listen` is kept for contract completeness (Service `onXxx` emitters map to
|
| 8 |
+
* it), but the `/api/v1/debug` surface is HTTP-only — the v2 event socket
|
| 9 |
+
* (`/api/v2/ws`) that used to serve it was removed — so the HTTP channel's
|
| 10 |
+
* `listen` throws and panels fetch on demand instead.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation';
|
| 14 |
+
|
| 15 |
+
export interface IDisposable {
|
| 16 |
+
dispose(): void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export interface Event<T> {
|
| 20 |
+
(listener: (event: T) => unknown, thisArg?: unknown, disposables?: IDisposable[]): IDisposable;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/** The client-facing channel contract. Calls always carry the complete argument array. */
|
| 24 |
+
export interface IChannel {
|
| 25 |
+
call<T>(command: string, args?: unknown[]): Promise<T>;
|
| 26 |
+
listen<T>(event: string, arg?: unknown): Event<T>;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/** A wire Service reference: a DI decorator (stringifies to the wire channel
|
| 30 |
+
* name) or the raw channel name as a string. */
|
| 31 |
+
export type ServiceRef<T> = ServiceIdentifier<T> | string;
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Remote view of a Service contract: every method becomes an async wire call;
|
| 35 |
+
* `onXxx` event members (`Event<T>` — callables returning `IDisposable`) stay
|
| 36 |
+
* subscribable events; plain non-function members become zero-arg property
|
| 37 |
+
* reads (the dispatcher returns non-function members as-is).
|
| 38 |
+
*/
|
| 39 |
+
export type ServiceProxy<T> = {
|
| 40 |
+
[K in keyof T]: T[K] extends (...args: infer A) => infer R
|
| 41 |
+
? R extends IDisposable
|
| 42 |
+
? T[K]
|
| 43 |
+
: (...args: A) => Promise<Awaited<R>>
|
| 44 |
+
: () => Promise<Awaited<T[K]>>;
|
| 45 |
+
};
|
apps/kimi-inspect/src/channel/channels.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Protocol loading — the debug surface's `GET /api/v1/debug/channels`
|
| 3 |
+
* endpoint is the server's self-description of every wire-callable Service
|
| 4 |
+
* (name, scope, domain, methods + properties), whitelist-free. Paired with
|
| 5 |
+
* `serviceByName`, each descriptor materializes 1:1 into a typed proxy of
|
| 6 |
+
* the channel layer: same channel name, same scope route, methods invoked by
|
| 7 |
+
* reflection.
|
| 8 |
+
*
|
| 9 |
+
* `/api/v1/debug` is the ONLY RPC surface this app talks to (mounted by
|
| 10 |
+
* kap-server with `--debug-endpoints` on a loopback bind); the v2 surface
|
| 11 |
+
* (`/api/v2` + `/api/v2/ws`) was removed server-side, so there is no
|
| 12 |
+
* fallback — `probeDebugSurface` fails the connection with a clear error.
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation';
|
| 16 |
+
|
| 17 |
+
import type { ServiceProxy } from './channel';
|
| 18 |
+
import { DEBUG_RPC_BASE, type InspectClient } from './client';
|
| 19 |
+
import { RPCError } from './errors';
|
| 20 |
+
|
| 21 |
+
/** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */
|
| 22 |
+
export type ChannelScope = 'app' | 'session' | 'agent';
|
| 23 |
+
|
| 24 |
+
/** Mirror of `ChannelDescriptor` in kap-server (`GET /api/v1/debug/channels`). */
|
| 25 |
+
export interface ChannelDescriptor {
|
| 26 |
+
readonly name: string;
|
| 27 |
+
readonly scope: ChannelScope;
|
| 28 |
+
readonly domain: string;
|
| 29 |
+
readonly methods: readonly {
|
| 30 |
+
readonly name: string;
|
| 31 |
+
readonly kind: 'method' | 'property';
|
| 32 |
+
readonly arity: number;
|
| 33 |
+
readonly params: string;
|
| 34 |
+
}[];
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/** Fetch the dynamic channel list (unwrapped from the project envelope). */
|
| 38 |
+
export async function fetchChannelDescriptors(
|
| 39 |
+
client: InspectClient,
|
| 40 |
+
): Promise<readonly ChannelDescriptor[]> {
|
| 41 |
+
const headers: Record<string, string> = {};
|
| 42 |
+
if (client.token !== undefined && client.token !== '') {
|
| 43 |
+
headers['authorization'] = `Bearer ${client.token}`;
|
| 44 |
+
}
|
| 45 |
+
const res = await fetch(`${client.baseUrl}${DEBUG_RPC_BASE}/channels`, { headers });
|
| 46 |
+
const envelope = (await res.json()) as {
|
| 47 |
+
code: number;
|
| 48 |
+
msg: string;
|
| 49 |
+
data: readonly ChannelDescriptor[];
|
| 50 |
+
};
|
| 51 |
+
if (envelope.code !== 0) throw new RPCError(envelope.code, envelope.msg);
|
| 52 |
+
return envelope.data;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Verify the server mounts the debug RPC surface before the client is built.
|
| 57 |
+
* Resolves silently when `GET /api/v1/debug/channels` answers with a
|
| 58 |
+
* zero-code envelope; otherwise throws an `Error` whose message tells the
|
| 59 |
+
* user exactly what is wrong (unreachable server, surface not mounted →
|
| 60 |
+
* start kap-server with `--debug-endpoints`, or a rejected probe → check the
|
| 61 |
+
* token).
|
| 62 |
+
*/
|
| 63 |
+
export async function probeDebugSurface(options: {
|
| 64 |
+
readonly baseUrl: string;
|
| 65 |
+
readonly token?: string;
|
| 66 |
+
}): Promise<void> {
|
| 67 |
+
const headers: Record<string, string> = {};
|
| 68 |
+
if (options.token !== undefined && options.token !== '') {
|
| 69 |
+
headers['authorization'] = `Bearer ${options.token}`;
|
| 70 |
+
}
|
| 71 |
+
const url = `${options.baseUrl.replace(/\/$/, '')}${DEBUG_RPC_BASE}/channels`;
|
| 72 |
+
let res: Response;
|
| 73 |
+
try {
|
| 74 |
+
res = await fetch(url, { headers });
|
| 75 |
+
} catch (error) {
|
| 76 |
+
const reason = error instanceof Error ? error.message : String(error);
|
| 77 |
+
throw new Error(`cannot reach ${options.baseUrl} — is kap-server running? (${reason})`);
|
| 78 |
+
}
|
| 79 |
+
if (!res.ok) {
|
| 80 |
+
throw new Error(
|
| 81 |
+
`GET ${DEBUG_RPC_BASE}/channels answered HTTP ${res.status} — this server does not ` +
|
| 82 |
+
'mount the debug RPC surface. Start kap-server with --debug-endpoints on a loopback bind.',
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
const envelope = (await res.json()) as { code?: number; msg?: string };
|
| 86 |
+
if (envelope.code !== 0) {
|
| 87 |
+
throw new Error(
|
| 88 |
+
`the debug surface rejected the probe (code ${envelope.code ?? '?'}: ${
|
| 89 |
+
envelope.msg ?? 'no message'
|
| 90 |
+
}) — check the bearer token.`,
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export interface ServiceTarget {
|
| 96 |
+
readonly scope: ChannelScope;
|
| 97 |
+
readonly sessionId?: string;
|
| 98 |
+
readonly agentId?: string;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Resolve a Service proxy by wire channel name. The DI decorator registry keys
|
| 103 |
+
* identifiers by name, so re-creating the decorator resolves to the same token
|
| 104 |
+
* the server channel registry created — the name is the wire channel, which is
|
| 105 |
+
* all the proxy uses. Returns `undefined` when the target scope needs a
|
| 106 |
+
* workspace/session/agent id that isn't available.
|
| 107 |
+
*/
|
| 108 |
+
export function serviceByName<T extends object>(
|
| 109 |
+
client: InspectClient,
|
| 110 |
+
name: string,
|
| 111 |
+
target: ServiceTarget,
|
| 112 |
+
): ServiceProxy<T> | undefined {
|
| 113 |
+
const id = createDecorator<T>(name);
|
| 114 |
+
if (target.scope === 'app') return client.core(id);
|
| 115 |
+
if (target.sessionId === undefined) return undefined;
|
| 116 |
+
const base = client.session(target.sessionId);
|
| 117 |
+
if (target.scope === 'session') return base.service(id);
|
| 118 |
+
if (target.agentId === undefined) return undefined;
|
| 119 |
+
return base.agent(target.agentId).service(id);
|
| 120 |
+
}
|
apps/kimi-inspect/src/channel/client.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Inspect client — the app's `/api/v1/debug` entry point, in the old-klient
|
| 3 |
+
* VS Code `ProxyChannel` model: a multi-level scope entry (`core` /
|
| 4 |
+
* `workspace` / `session` / `agent`) whose every Service handle is a
|
| 5 |
+
* `makeProxy`-materialized typed proxy over a service-bound HTTP channel.
|
| 6 |
+
*
|
| 7 |
+
* const client = createInspectClient({ url: 'http://127.0.0.1:58627' });
|
| 8 |
+
* await client.core(ISessionIndex).listRecent({});
|
| 9 |
+
* await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1');
|
| 10 |
+
* await client.session('s1').service(ISessionMetadata).read();
|
| 11 |
+
* await client.session('s1').agent('main').service(IAgentLoopService).cancel(undefined);
|
| 12 |
+
*
|
| 13 |
+
* The `agent-core-v2` service token is the whole key: its type parameter `T`
|
| 14 |
+
* types the returned proxy, and its decorator id (`String(id)`) is the channel
|
| 15 |
+
* name in the URL. Calls ride HTTP (`ProxyChannel`). There is no event
|
| 16 |
+
* transport: the v2 socket (`/api/v2/ws`) that used to carry Service `onXxx`
|
| 17 |
+
* emitters and scope event streams was removed server-side, so the UI reads
|
| 18 |
+
* Service state on demand. (The transcript's own `/api/v1/ws` delta channel
|
| 19 |
+
* lives in `src/transcript/` and is unrelated to this client.)
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
import type { ServiceProxy, ServiceRef } from './channel';
|
| 23 |
+
import { makeProxy } from './proxy';
|
| 24 |
+
import { ProxyChannel } from './proxyChannel';
|
| 25 |
+
|
| 26 |
+
/** The dev server's whitelist-free debug surface (`--debug-endpoints` + loopback). */
|
| 27 |
+
export const DEBUG_RPC_BASE = '/api/v1/debug' as const;
|
| 28 |
+
|
| 29 |
+
export interface InspectAgentHandle {
|
| 30 |
+
service<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export interface InspectSessionHandle extends InspectAgentHandle {
|
| 34 |
+
agent(agentId: string): InspectAgentHandle;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export interface InspectClient {
|
| 38 |
+
/** Absolute server base URL, e.g. `http://127.0.0.1:58627`. */
|
| 39 |
+
readonly baseUrl: string;
|
| 40 |
+
/** Bearer token in use, when any. */
|
| 41 |
+
readonly token?: string;
|
| 42 |
+
core<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
|
| 43 |
+
session(sessionId: string): InspectSessionHandle;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export interface InspectClientOptions {
|
| 47 |
+
/** Base URL of the server, e.g. `http://127.0.0.1:58627`. */
|
| 48 |
+
readonly url: string;
|
| 49 |
+
/** Optional bearer token. */
|
| 50 |
+
readonly token?: string;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function createInspectClient(options: InspectClientOptions): InspectClient {
|
| 54 |
+
const url = options.url.replace(/\/$/, '');
|
| 55 |
+
|
| 56 |
+
/** Materialize a typed proxy for one Service on one scope binding. */
|
| 57 |
+
function proxy<T extends object>(scopePath: string, id: ServiceRef<T>): ServiceProxy<T> {
|
| 58 |
+
const service = String(id);
|
| 59 |
+
return makeProxy<T>(
|
| 60 |
+
new ProxyChannel({
|
| 61 |
+
baseUrl: `${url}${DEBUG_RPC_BASE}${scopePath}/${service}`,
|
| 62 |
+
token: options.token,
|
| 63 |
+
}),
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
baseUrl: url,
|
| 69 |
+
token: options.token,
|
| 70 |
+
core: (id) => proxy('', id),
|
| 71 |
+
session: (sessionId) => {
|
| 72 |
+
const scopePath = `/session/${encodeURIComponent(sessionId)}`;
|
| 73 |
+
return {
|
| 74 |
+
service: (id) => proxy(scopePath, id),
|
| 75 |
+
agent: (agentId) => ({
|
| 76 |
+
service: (subId) => proxy(`${scopePath}/agent/${encodeURIComponent(agentId)}`, subId),
|
| 77 |
+
}),
|
| 78 |
+
};
|
| 79 |
+
},
|
| 80 |
+
};
|
| 81 |
+
}
|
apps/kimi-inspect/src/channel/errors.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side RPC error surfaced when the debug-RPC envelope carries a
|
| 3 |
+
* non-zero `code`. Mirrors the server envelope (`{ code, msg, data,
|
| 4 |
+
* request_id }`) — the numeric `code` is the stable branch key across the
|
| 5 |
+
* wire, not `instanceof`.
|
| 6 |
+
*/
|
| 7 |
+
export class RPCError extends Error {
|
| 8 |
+
constructor(
|
| 9 |
+
readonly code: number,
|
| 10 |
+
message: string,
|
| 11 |
+
readonly details?: unknown,
|
| 12 |
+
) {
|
| 13 |
+
super(message);
|
| 14 |
+
this.name = 'RPCError';
|
| 15 |
+
}
|
| 16 |
+
}
|
apps/kimi-inspect/src/channel/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export * from './channel';
|
| 2 |
+
export * from './channels';
|
| 3 |
+
export * from './client';
|
| 4 |
+
export * from './errors';
|
| 5 |
+
export * from './proxy';
|
| 6 |
+
export * from './proxyChannel';
|
| 7 |
+
export * from './wsLike';
|
apps/kimi-inspect/src/channel/proxy.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Typed proxy turning an `IChannel` (bound to one Service) into a value
|
| 3 |
+
* satisfying that Service's interface `T` — VS Code's `ProxyChannel.toService`.
|
| 4 |
+
*
|
| 5 |
+
* Members named `onUpperCase` become channel events; every other property access
|
| 6 |
+
* becomes a function forwarding its complete argument array to `channel.call`
|
| 7 |
+
* (the dispatcher also answers property reads this way). The shared interface
|
| 8 |
+
* `T` is the whole contract, with no per-method allowlist or renaming.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import type { IChannel, ServiceProxy } from './channel';
|
| 12 |
+
|
| 13 |
+
export function makeProxy<T extends object>(channel: IChannel): ServiceProxy<T> {
|
| 14 |
+
return new Proxy({} as ServiceProxy<T>, {
|
| 15 |
+
get(_target, prop) {
|
| 16 |
+
if (typeof prop !== 'string') return undefined;
|
| 17 |
+
if (/^on[A-Z]/.test(prop)) return channel.listen(prop);
|
| 18 |
+
return (...args: unknown[]) => channel.call(prop, args);
|
| 19 |
+
},
|
| 20 |
+
});
|
| 21 |
+
}
|
apps/kimi-inspect/src/channel/proxyChannel.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* `ProxyChannel` — an `IChannel` bound to one Service, routing `call`s to
|
| 3 |
+
* kap-server's `/api/v1/debug` HTTP surface. Every call `POST`s the method
|
| 4 |
+
* name to the Service base URL with the complete argument array as the JSON
|
| 5 |
+
* body, then unwraps the project envelope: a non-zero `code` throws
|
| 6 |
+
* `RPCError`, otherwise `data` is returned. Non-function members answer as
|
| 7 |
+
* property reads through the same route (the dispatcher returns them as-is).
|
| 8 |
+
*
|
| 9 |
+
* `listen` cannot be served by HTTP: the v2 event socket (`/api/v2/ws`) that
|
| 10 |
+
* used to back Service emitter events was removed, so `listen` throws and
|
| 11 |
+
* the UI fetches Service state on demand instead.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import type { Event, IChannel } from './channel';
|
| 15 |
+
import { RPCError } from './errors';
|
| 16 |
+
|
| 17 |
+
interface Envelope<T> {
|
| 18 |
+
readonly code: number;
|
| 19 |
+
readonly msg: string;
|
| 20 |
+
readonly data: T;
|
| 21 |
+
readonly request_id: string;
|
| 22 |
+
readonly details?: unknown;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export interface ProxyChannelOptions {
|
| 26 |
+
/** Service base URL, e.g. `http://127.0.0.1:58627/api/v1/debug[/session/:sid[/agent/:aid]]/:service`. */
|
| 27 |
+
readonly baseUrl: string;
|
| 28 |
+
/** Optional bearer token. */
|
| 29 |
+
readonly token?: string;
|
| 30 |
+
/** `fetch` implementation; defaults to the global `fetch`. */
|
| 31 |
+
readonly fetch?: typeof fetch;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export class ProxyChannel implements IChannel {
|
| 35 |
+
private readonly baseUrl: string;
|
| 36 |
+
private readonly token?: string;
|
| 37 |
+
private readonly fetchImpl: typeof fetch;
|
| 38 |
+
|
| 39 |
+
constructor(opts: ProxyChannelOptions) {
|
| 40 |
+
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
| 41 |
+
this.token = opts.token;
|
| 42 |
+
// Bind the global fetch: browsers throw "Illegal invocation" when the
|
| 43 |
+
// native function is invoked with a non-Window receiver.
|
| 44 |
+
this.fetchImpl = opts.fetch ?? fetch.bind(globalThis);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async call<T>(command: string, args: unknown[] = []): Promise<T> {
|
| 48 |
+
const headers: Record<string, string> = {};
|
| 49 |
+
let body: string | undefined;
|
| 50 |
+
if (args.length > 0) {
|
| 51 |
+
headers['content-type'] = 'application/json';
|
| 52 |
+
body = JSON.stringify(args);
|
| 53 |
+
}
|
| 54 |
+
if (this.token !== undefined) {
|
| 55 |
+
headers['authorization'] = `Bearer ${this.token}`;
|
| 56 |
+
}
|
| 57 |
+
const res = await this.fetchImpl(`${this.baseUrl}/${command}`, {
|
| 58 |
+
method: 'POST',
|
| 59 |
+
headers,
|
| 60 |
+
body,
|
| 61 |
+
});
|
| 62 |
+
const envelope = (await res.json()) as Envelope<T>;
|
| 63 |
+
if (envelope.code !== 0) {
|
| 64 |
+
throw new RPCError(envelope.code, envelope.msg, envelope.details);
|
| 65 |
+
}
|
| 66 |
+
return envelope.data;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
listen<T>(_event: string): Event<T> {
|
| 70 |
+
throw new Error(
|
| 71 |
+
'events are not supported on this channel (HTTP-only; the /api/v2/ws event socket was removed)',
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
}
|
apps/kimi-inspect/src/channel/wsLike.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Minimal DOM-compatible WebSocket surface shared by the app's socket
|
| 3 |
+
* clients (today only the transcript `/api/v1/ws` client). Coding against
|
| 4 |
+
* this structural type keeps the clients testable with an injected fake;
|
| 5 |
+
* the default is the global `WebSocket` (browsers, Node ≥ 21).
|
| 6 |
+
*/
|
| 7 |
+
export interface WsLike {
|
| 8 |
+
readonly readyState: number;
|
| 9 |
+
send(data: string): void;
|
| 10 |
+
close(code?: number, reason?: string): void;
|
| 11 |
+
addEventListener(
|
| 12 |
+
type: 'open' | 'message' | 'close' | 'error',
|
| 13 |
+
listener: (event: never) => void,
|
| 14 |
+
): void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface WsLikeCtor {
|
| 18 |
+
new (url: string, protocols?: string | string[]): WsLike;
|
| 19 |
+
readonly OPEN: number;
|
| 20 |
+
}
|
apps/kimi-inspect/src/components/AppServicesView.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* App Services view — the app-scope (server-level) Service reflection as a
|
| 3 |
+
* standalone rail view. Postman-style three-pane layout
|
| 4 |
+
* (`ScopePanelsScrollspy`): the Service list on the left, every Service's
|
| 5 |
+
* methods expanded in one continuously scrolling column in the middle
|
| 6 |
+
* (scroll position and left-side highlight kept in sync), and a
|
| 7 |
+
* request/response call history on the right. The proxies resolve on the
|
| 8 |
+
* `core` route, so this view works before any session is selected.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { useCallback } from 'react';
|
| 12 |
+
|
| 13 |
+
import { serviceByName } from '../channel';
|
| 14 |
+
import { useConnection } from '../connection';
|
| 15 |
+
import type { AnyService } from '../panels';
|
| 16 |
+
import { ScopePanelsScrollspy } from './ServicePanels';
|
| 17 |
+
|
| 18 |
+
export function AppServicesView() {
|
| 19 |
+
const { klient } = useConnection();
|
| 20 |
+
const proxyFor = useCallback(
|
| 21 |
+
(name: string): AnyService | null =>
|
| 22 |
+
serviceByName<AnyService>(klient, name, { scope: 'app' }) ?? null,
|
| 23 |
+
[klient],
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
return <ScopePanelsScrollspy scope="app" title="App Services" proxyFor={proxyFor} />;
|
| 27 |
+
}
|
apps/kimi-inspect/src/components/BashParserView.tsx
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Bash Parser view — a playground for the App-scope `IBashParserService`
|
| 3 |
+
* (the `bashParser` domain, a thin adapter over `@moonshot-ai/tree-sitter-bash`).
|
| 4 |
+
*
|
| 5 |
+
* left: the bash source textarea plus the parse budget (timeoutMs /
|
| 6 |
+
* maxNodes, empty = package default); the `examples…` dropdown
|
| 7 |
+
* fills the textarea with curated snippets from the parser's own
|
| 8 |
+
* differential fixtures;
|
| 9 |
+
* right: the parse result — status badges (hasError / aborted / node
|
| 10 |
+
* count) and the syntax tree, one row per node with its type,
|
| 11 |
+
* UTF-16 range and (for leaves) the source text. Anonymous tokens
|
| 12 |
+
* are dimmed; rows expand/collapse.
|
| 13 |
+
*
|
| 14 |
+
* Parsing is debounced off the textarea and rides the same `/api/v1/debug`
|
| 15 |
+
* channel as every other panel (`klient.core(IBashParserService).parse`) —
|
| 16 |
+
* the budgeted parse never throws, `{ ok: false }` means budget exhaustion.
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
import { useEffect, useState } from 'react';
|
| 20 |
+
|
| 21 |
+
import {
|
| 22 |
+
IBashParserService,
|
| 23 |
+
type BashParseResult,
|
| 24 |
+
type BashSyntaxNode,
|
| 25 |
+
} from '@moonshot-ai/agent-core-v2/app/bashParser/bashParser';
|
| 26 |
+
|
| 27 |
+
import { useConnection } from '../connection';
|
| 28 |
+
import { Badge, errorMessage } from '../ui';
|
| 29 |
+
|
| 30 |
+
const DEFAULT_SOURCE = `if [ -f config.sh ]; then
|
| 31 |
+
source config.sh && echo "loaded" | tee -a setup.log
|
| 32 |
+
else
|
| 33 |
+
echo "missing" >&2; exit 1
|
| 34 |
+
fi
|
| 35 |
+
`;
|
| 36 |
+
|
| 37 |
+
const PARSE_DEBOUNCE_MS = 300;
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Quick-fill examples, adapted from the parser's own differential fixtures
|
| 41 |
+
* (`packages/tree-sitter-bash/test/fixtures/differential/*.txt`) — each one
|
| 42 |
+
* exercises a distinct area of the grammar. The last three probe the
|
| 43 |
+
* non-happy paths: deep nesting (a left-associative arithmetic chain, the
|
| 44 |
+
* case that once overflowed the DTO conversion) and the error-recovery
|
| 45 |
+
* paths that set `hasError`.
|
| 46 |
+
*/
|
| 47 |
+
const EXAMPLES: readonly { readonly name: string; readonly source: string }[] = [
|
| 48 |
+
{
|
| 49 |
+
name: 'deep arithmetic (1000 operands)',
|
| 50 |
+
// A thousand left-nested binary_expression levels. Deeper chains parse
|
| 51 |
+
// fine in-process, but past ~2500 levels the JSON RPC transport itself
|
| 52 |
+
// cannot serialize the tree (V8 call-stack limit in JSON.stringify).
|
| 53 |
+
source: `echo $((${'1+'.repeat(1000)}1))`,
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
name: 'pipeline & redirects',
|
| 57 |
+
source: `git log --oneline | head -20 | tee /tmp/log.txt
|
| 58 |
+
find . -name '*.ts' -print0 2>/dev/null | xargs -0 grep -l TODO
|
| 59 |
+
cmd <<< "$input" >out.txt 2>&1
|
| 60 |
+
`,
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
name: 'case statement',
|
| 64 |
+
source: `case $x in
|
| 65 |
+
a) echo A ;;
|
| 66 |
+
b|c) echo BC ;&
|
| 67 |
+
foo*|bar) echo match ;;
|
| 68 |
+
[a-z]) echo lower ;;
|
| 69 |
+
*) echo other ;;
|
| 70 |
+
esac
|
| 71 |
+
`,
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
name: 'heredoc',
|
| 75 |
+
source: `foo() { cat <<EOF
|
| 76 |
+
body $x
|
| 77 |
+
EOF
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
cat <<-'RAW'
|
| 81 |
+
indented $notexpanded
|
| 82 |
+
RAW
|
| 83 |
+
`,
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
name: 'expansions & arithmetic',
|
| 87 |
+
source: `echo "\${var:-default} \${#arr[@]} \${path##*/}"
|
| 88 |
+
echo $((x << 2 | 1)) $[y + 1]
|
| 89 |
+
result=$(command -v jq) && echo "$result"
|
| 90 |
+
`,
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
name: 'process substitution',
|
| 94 |
+
source: `diff <(sort a.txt) <(sort b.txt)
|
| 95 |
+
while read -r line; do echo "$line"; done < <(git status --short)
|
| 96 |
+
`,
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
name: 'loops & conditions',
|
| 100 |
+
source: `for f in src/*.ts; do
|
| 101 |
+
[ -f "$f" ] || continue
|
| 102 |
+
grep -q FIXME "$f" && echo "$f has FIXME"
|
| 103 |
+
done
|
| 104 |
+
|
| 105 |
+
while IFS= read -r line; do printf '%s\\n' "$line"; done < list.txt
|
| 106 |
+
until [ "$n" -le 0 ]; do n=$((n - 1)); done
|
| 107 |
+
`,
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
name: 'error: unclosed substitution',
|
| 111 |
+
source: 'echo $(date | wc -l\n',
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
name: 'error: unterminated if',
|
| 115 |
+
source: `if cat <<EOF; then x; fi
|
| 116 |
+
body
|
| 117 |
+
EOF
|
| 118 |
+
`,
|
| 119 |
+
},
|
| 120 |
+
];
|
| 121 |
+
|
| 122 |
+
function countNodes(node: BashSyntaxNode): number {
|
| 123 |
+
return 1 + node.children.reduce((sum, child) => sum + countNodes(child), 0);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export function BashParserView() {
|
| 127 |
+
const { klient } = useConnection();
|
| 128 |
+
const [source, setSource] = useState(DEFAULT_SOURCE);
|
| 129 |
+
const [timeoutMs, setTimeoutMs] = useState('');
|
| 130 |
+
const [maxNodes, setMaxNodes] = useState('');
|
| 131 |
+
const [result, setResult] = useState<BashParseResult | null>(null);
|
| 132 |
+
const [error, setError] = useState<string | null>(null);
|
| 133 |
+
|
| 134 |
+
useEffect(() => {
|
| 135 |
+
const handle = setTimeout(() => {
|
| 136 |
+
klient
|
| 137 |
+
.core(IBashParserService)
|
| 138 |
+
.parse(source, {
|
| 139 |
+
timeoutMs: timeoutMs === '' ? undefined : Number(timeoutMs),
|
| 140 |
+
maxNodes: maxNodes === '' ? undefined : Number(maxNodes),
|
| 141 |
+
})
|
| 142 |
+
.then(setResult, (e: unknown) => {
|
| 143 |
+
setResult(null);
|
| 144 |
+
setError(errorMessage(e));
|
| 145 |
+
});
|
| 146 |
+
}, PARSE_DEBOUNCE_MS);
|
| 147 |
+
return () => {
|
| 148 |
+
clearTimeout(handle);
|
| 149 |
+
};
|
| 150 |
+
}, [klient, source, timeoutMs, maxNodes]);
|
| 151 |
+
|
| 152 |
+
const nodeCount = result !== null && result.ok ? countNodes(result.root) : null;
|
| 153 |
+
|
| 154 |
+
return (
|
| 155 |
+
<div className="flex min-h-0 flex-1">
|
| 156 |
+
<div className="flex w-[44%] shrink-0 flex-col border-r border-neutral-800">
|
| 157 |
+
<div className="flex items-center gap-3 border-b border-neutral-800 px-3 py-2">
|
| 158 |
+
<span className="text-[11px] font-semibold text-neutral-300">bash source</span>
|
| 159 |
+
<select
|
| 160 |
+
className="rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[11px] text-neutral-300 outline-none focus:border-sky-600"
|
| 161 |
+
value=""
|
| 162 |
+
onChange={(e) => {
|
| 163 |
+
const example = EXAMPLES.find((x) => x.name === e.target.value);
|
| 164 |
+
if (example !== undefined) setSource(example.source);
|
| 165 |
+
}}
|
| 166 |
+
>
|
| 167 |
+
<option value="" disabled>
|
| 168 |
+
examples…
|
| 169 |
+
</option>
|
| 170 |
+
{EXAMPLES.map((x) => (
|
| 171 |
+
<option key={x.name} value={x.name}>
|
| 172 |
+
{x.name}
|
| 173 |
+
</option>
|
| 174 |
+
))}
|
| 175 |
+
</select>
|
| 176 |
+
<div className="flex-1" />
|
| 177 |
+
<label className="flex items-center gap-1 text-[10px] text-neutral-500">
|
| 178 |
+
timeoutMs
|
| 179 |
+
<input
|
| 180 |
+
className="w-16 rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 font-mono text-[11px] text-neutral-200 outline-none focus:border-sky-600"
|
| 181 |
+
placeholder="50"
|
| 182 |
+
value={timeoutMs}
|
| 183 |
+
onChange={(e) => setTimeoutMs(e.target.value)}
|
| 184 |
+
/>
|
| 185 |
+
</label>
|
| 186 |
+
<label className="flex items-center gap-1 text-[10px] text-neutral-500">
|
| 187 |
+
maxNodes
|
| 188 |
+
<input
|
| 189 |
+
className="w-20 rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 font-mono text-[11px] text-neutral-200 outline-none focus:border-sky-600"
|
| 190 |
+
placeholder="50000"
|
| 191 |
+
value={maxNodes}
|
| 192 |
+
onChange={(e) => setMaxNodes(e.target.value)}
|
| 193 |
+
/>
|
| 194 |
+
</label>
|
| 195 |
+
</div>
|
| 196 |
+
<textarea
|
| 197 |
+
className="min-h-0 flex-1 resize-none bg-neutral-950 p-3 font-mono text-[12px] leading-relaxed text-neutral-200 outline-none"
|
| 198 |
+
spellCheck={false}
|
| 199 |
+
value={source}
|
| 200 |
+
onChange={(e) => setSource(e.target.value)}
|
| 201 |
+
/>
|
| 202 |
+
</div>
|
| 203 |
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
| 204 |
+
<div className="flex items-center gap-2 border-b border-neutral-800 px-3 py-2">
|
| 205 |
+
<span className="text-[11px] font-semibold text-neutral-300">syntax tree</span>
|
| 206 |
+
{error !== null ? <Badge tone="red">rpc error</Badge> : null}
|
| 207 |
+
{result !== null && !result.ok ? <Badge tone="red">aborted</Badge> : null}
|
| 208 |
+
{result !== null && result.ok && result.hasError ? (
|
| 209 |
+
<Badge tone="amber">hasError</Badge>
|
| 210 |
+
) : null}
|
| 211 |
+
{nodeCount !== null ? <Badge tone="neutral">{nodeCount} nodes</Badge> : null}
|
| 212 |
+
</div>
|
| 213 |
+
<div className="min-h-0 flex-1 overflow-auto p-2">
|
| 214 |
+
{error !== null ? (
|
| 215 |
+
<div className="rounded bg-red-950/50 px-2 py-1 text-[11px] text-red-400">{error}</div>
|
| 216 |
+
) : result === null ? (
|
| 217 |
+
<div className="text-[11px] text-neutral-600 italic">parsing…</div>
|
| 218 |
+
) : !result.ok ? (
|
| 219 |
+
<div className="text-[11px] text-neutral-500">
|
| 220 |
+
Parse budget exhausted (<span className="font-mono">reason: {result.reason}</span>) —
|
| 221 |
+
the tree cannot be analyzed; raise the budget or shrink the input.
|
| 222 |
+
</div>
|
| 223 |
+
) : (
|
| 224 |
+
<SyntaxTreeNode node={result.root} depth={0} defaultDepth={2} />
|
| 225 |
+
)}
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function SyntaxTreeNode({
|
| 233 |
+
node,
|
| 234 |
+
depth,
|
| 235 |
+
defaultDepth,
|
| 236 |
+
}: {
|
| 237 |
+
readonly node: BashSyntaxNode;
|
| 238 |
+
readonly depth: number;
|
| 239 |
+
readonly defaultDepth: number;
|
| 240 |
+
}) {
|
| 241 |
+
const [open, setOpen] = useState(depth < defaultDepth);
|
| 242 |
+
const expandable = node.children.length > 0;
|
| 243 |
+
const range = `[${String(node.startIndex)}, ${String(node.endIndex)})`;
|
| 244 |
+
const text = node.text.length > 60 ? `${node.text.slice(0, 60)}…` : node.text;
|
| 245 |
+
|
| 246 |
+
return (
|
| 247 |
+
<div>
|
| 248 |
+
<div
|
| 249 |
+
className="flex cursor-pointer items-baseline gap-2 truncate rounded px-1 font-mono text-[11px] leading-[1.7] hover:bg-neutral-800/70"
|
| 250 |
+
style={{ paddingLeft: `${depth * 14 + 4}px` }}
|
| 251 |
+
onClick={() => {
|
| 252 |
+
if (expandable) setOpen((v) => !v);
|
| 253 |
+
}}
|
| 254 |
+
title={node.text}
|
| 255 |
+
>
|
| 256 |
+
<span className="select-none text-neutral-600">
|
| 257 |
+
{expandable ? (open ? '▾' : '▸') : '·'}
|
| 258 |
+
</span>
|
| 259 |
+
<span className={node.isNamed ? 'text-sky-300' : 'text-neutral-500'}>{node.type}</span>
|
| 260 |
+
<span className="text-neutral-600">{range}</span>
|
| 261 |
+
{!expandable ? (
|
| 262 |
+
<span className="truncate text-emerald-300/70">{JSON.stringify(text)}</span>
|
| 263 |
+
) : null}
|
| 264 |
+
</div>
|
| 265 |
+
{open
|
| 266 |
+
? node.children.map((child, index) => (
|
| 267 |
+
<SyntaxTreeNode
|
| 268 |
+
// Children are source-ordered and sibling ranges never overlap,
|
| 269 |
+
// so range + index is a stable key.
|
| 270 |
+
key={`${String(child.startIndex)}:${String(child.endIndex)}:${String(index)}`}
|
| 271 |
+
node={child}
|
| 272 |
+
depth={depth + 1}
|
| 273 |
+
defaultDepth={defaultDepth}
|
| 274 |
+
/>
|
| 275 |
+
))
|
| 276 |
+
: null}
|
| 277 |
+
</div>
|
| 278 |
+
);
|
| 279 |
+
}
|
apps/kimi-inspect/src/components/ChatSearchBar.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* In-chat search bar — searches the messages of the CURRENT session only
|
| 3 |
+
* (`container: { session_id }` on `POST /api/v1/search`; the whole session,
|
| 4 |
+
* not just the active agent) and renders the first page as a dropdown under
|
| 5 |
+
* the input. Clicking a hit hands it to the app shell (`onOpenHit`), which
|
| 6 |
+
* switches the agent when the hit belongs to another one and jumps the
|
| 7 |
+
* transcript to the hit's turn / step. Esc or an outside click closes the
|
| 8 |
+
* panel.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { useEffect, useRef, useState } from 'react';
|
| 12 |
+
|
| 13 |
+
import { useConnection } from '../connection';
|
| 14 |
+
import { fetchSearchPage, type SearchHit } from '../search/api';
|
| 15 |
+
import { Badge, ErrorLine, relTime } from '../ui';
|
| 16 |
+
|
| 17 |
+
const PAGE_SIZE = 20;
|
| 18 |
+
|
| 19 |
+
export function ChatSearchBar({
|
| 20 |
+
sessionId,
|
| 21 |
+
onOpenHit,
|
| 22 |
+
}: {
|
| 23 |
+
sessionId: string;
|
| 24 |
+
onOpenHit?: ((hit: SearchHit) => void) | undefined;
|
| 25 |
+
}) {
|
| 26 |
+
const { baseUrl, config } = useConnection();
|
| 27 |
+
const [input, setInput] = useState('');
|
| 28 |
+
const [open, setOpen] = useState(false);
|
| 29 |
+
const [hits, setHits] = useState<readonly SearchHit[] | null>(null);
|
| 30 |
+
const [searching, setSearching] = useState(false);
|
| 31 |
+
const [error, setError] = useState<unknown>(null);
|
| 32 |
+
const rootRef = useRef<HTMLDivElement>(null);
|
| 33 |
+
|
| 34 |
+
// A session switch drops the previous session's query and results.
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
setInput('');
|
| 37 |
+
setHits(null);
|
| 38 |
+
setError(null);
|
| 39 |
+
setOpen(false);
|
| 40 |
+
}, [sessionId]);
|
| 41 |
+
|
| 42 |
+
// Clicking outside the bar closes the result panel.
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
if (!open) return;
|
| 45 |
+
const onDown = (event: MouseEvent) => {
|
| 46 |
+
if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) {
|
| 47 |
+
setOpen(false);
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
document.addEventListener('mousedown', onDown);
|
| 51 |
+
return () => {
|
| 52 |
+
document.removeEventListener('mousedown', onDown);
|
| 53 |
+
};
|
| 54 |
+
}, [open]);
|
| 55 |
+
|
| 56 |
+
const search = async () => {
|
| 57 |
+
const query = input.trim();
|
| 58 |
+
if (query === '' || searching) return;
|
| 59 |
+
setSearching(true);
|
| 60 |
+
setError(null);
|
| 61 |
+
setOpen(true);
|
| 62 |
+
try {
|
| 63 |
+
const token = config.token.trim();
|
| 64 |
+
const page = await fetchSearchPage({
|
| 65 |
+
baseUrl,
|
| 66 |
+
token: token === '' ? undefined : token,
|
| 67 |
+
query,
|
| 68 |
+
// Substring semantics (Ctrl+F-like): terms mode is whole-token matching,
|
| 69 |
+
// which surprises in an in-session search box.
|
| 70 |
+
mode: 'literal',
|
| 71 |
+
container: { sessionId },
|
| 72 |
+
pageSize: PAGE_SIZE,
|
| 73 |
+
});
|
| 74 |
+
setHits(page.items);
|
| 75 |
+
} catch (error) {
|
| 76 |
+
setHits(null);
|
| 77 |
+
setError(error);
|
| 78 |
+
} finally {
|
| 79 |
+
setSearching(false);
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
return (
|
| 84 |
+
<div ref={rootRef} className="relative border-b border-neutral-800 px-4 py-2">
|
| 85 |
+
<input
|
| 86 |
+
className="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-[13px] text-neutral-100 outline-none focus:border-sky-600"
|
| 87 |
+
placeholder="Search this session… (Enter to search, Esc to close)"
|
| 88 |
+
value={input}
|
| 89 |
+
onChange={(e) => setInput(e.target.value)}
|
| 90 |
+
onKeyDown={(e) => {
|
| 91 |
+
if (e.key === 'Enter') {
|
| 92 |
+
e.preventDefault();
|
| 93 |
+
void search();
|
| 94 |
+
} else if (e.key === 'Escape') {
|
| 95 |
+
setOpen(false);
|
| 96 |
+
}
|
| 97 |
+
}}
|
| 98 |
+
onFocus={() => {
|
| 99 |
+
if (hits !== null) setOpen(true);
|
| 100 |
+
}}
|
| 101 |
+
/>
|
| 102 |
+
{open ? (
|
| 103 |
+
<div className="absolute inset-x-4 top-full z-10 mt-1 max-h-80 overflow-y-auto rounded-lg border border-neutral-800 bg-neutral-900 p-2 shadow-lg shadow-black/40">
|
| 104 |
+
{searching ? (
|
| 105 |
+
<div className="px-1 py-1 text-[12px] text-neutral-600 italic">Searching…</div>
|
| 106 |
+
) : error !== null ? (
|
| 107 |
+
<ErrorLine error={error} />
|
| 108 |
+
) : hits === null ? null : hits.length === 0 ? (
|
| 109 |
+
<div className="px-1 py-1 text-[12px] text-neutral-600 italic">
|
| 110 |
+
No hits in this session.
|
| 111 |
+
</div>
|
| 112 |
+
) : (
|
| 113 |
+
<div className="flex flex-col gap-1">
|
| 114 |
+
{hits.map((hit, i) => (
|
| 115 |
+
<button
|
| 116 |
+
key={`${hit.agentId}/${hit.time}/${i}`}
|
| 117 |
+
type="button"
|
| 118 |
+
className="w-full rounded border border-transparent px-2 py-1.5 text-left transition-colors hover:border-sky-800 hover:bg-neutral-800/60"
|
| 119 |
+
onClick={() => {
|
| 120 |
+
setOpen(false);
|
| 121 |
+
onOpenHit?.(hit);
|
| 122 |
+
}}
|
| 123 |
+
title="Open in chat"
|
| 124 |
+
>
|
| 125 |
+
<div className="mb-1 flex flex-wrap items-center gap-2">
|
| 126 |
+
<Badge
|
| 127 |
+
tone={
|
| 128 |
+
hit.role === 'user' ? 'sky' : hit.role === 'assistant' ? 'green' : 'violet'
|
| 129 |
+
}
|
| 130 |
+
>
|
| 131 |
+
{hit.role}
|
| 132 |
+
</Badge>
|
| 133 |
+
{hit.agentId !== '' ? <Badge tone="neutral">agent: {hit.agentId}</Badge> : null}
|
| 134 |
+
<span className="ml-auto text-[10px] text-neutral-600">{relTime(hit.time)}</span>
|
| 135 |
+
</div>
|
| 136 |
+
<div className="whitespace-pre-wrap text-[12px] text-neutral-300">
|
| 137 |
+
{hit.snippet}
|
| 138 |
+
</div>
|
| 139 |
+
</button>
|
| 140 |
+
))}
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
) : null}
|
| 145 |
+
</div>
|
| 146 |
+
);
|
| 147 |
+
}
|
apps/kimi-inspect/src/components/ChatView.tsx
ADDED
|
@@ -0,0 +1,1231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Main view — the conversation of the active session + agent, rendered from
|
| 3 |
+
* the message protocol (`/api/v3/ws` + `GET /api/v1/sessions/{id}/history`):
|
| 4 |
+
*
|
| 5 |
+
* - Persisted state comes from the REST history endpoint only: the initial
|
| 6 |
+
* load reads the newest page, a full refresh re-reads it and re-covers
|
| 7 |
+
* the previously loaded window, and "load earlier" pages further with a
|
| 8 |
+
* `before_turn` cursor.
|
| 9 |
+
* - The WS channel carries the recovery payload and all live traffic; both
|
| 10 |
+
* are applied to the store through the same idempotent replace-by-id
|
| 11 |
+
* path (delta family appended by id, entity content authoritative), so
|
| 12 |
+
* there is no reset/buffer/cursor machinery.
|
| 13 |
+
* - Every subscribe ack (initial and reconnect) triggers an `after_step`
|
| 14 |
+
* catch-up from the newest terminal step; an empty catch-up whose
|
| 15 |
+
* anchor vanished (undo/clear while away) falls back to a full refresh.
|
| 16 |
+
*
|
| 17 |
+
* Rendering groups the flat timeline by turn (system markers stay
|
| 18 |
+
* standalone) and is typed entirely by the protocol schemas
|
| 19 |
+
* (`@moonshot-ai/kap-server/protocol`). Cancels go through the
|
| 20 |
+
* `agentLoopService` channel over the debug RPC
|
| 21 |
+
* surface (`/api/v1/debug`); interaction answers (approve/reject,
|
| 22 |
+
* answer/dismiss) go through the public REST endpoints
|
| 23 |
+
* (`src/interactions/api.ts`); the running indicator derives from
|
| 24 |
+
* `session.state`.
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop';
|
| 28 |
+
import type {
|
| 29 |
+
AssistantMessage,
|
| 30 |
+
ContentPart,
|
| 31 |
+
InteractionMessage,
|
| 32 |
+
InteractionQuestionItem,
|
| 33 |
+
SessionStateMessage,
|
| 34 |
+
StepMessage,
|
| 35 |
+
SystemMessage,
|
| 36 |
+
TaskMessage,
|
| 37 |
+
ThinkingMessage,
|
| 38 |
+
TodoMessage,
|
| 39 |
+
ToolCallMessage,
|
| 40 |
+
TurnMessage,
|
| 41 |
+
UserMessage,
|
| 42 |
+
} from '@moonshot-ai/kap-server/protocol';
|
| 43 |
+
import {
|
| 44 |
+
createContext,
|
| 45 |
+
useCallback,
|
| 46 |
+
useContext,
|
| 47 |
+
useEffect,
|
| 48 |
+
useLayoutEffect,
|
| 49 |
+
useMemo,
|
| 50 |
+
useRef,
|
| 51 |
+
useState,
|
| 52 |
+
useSyncExternalStore,
|
| 53 |
+
} from 'react';
|
| 54 |
+
|
| 55 |
+
import { AuditTrail } from '../audit/trail';
|
| 56 |
+
import { useConnection } from '../connection';
|
| 57 |
+
import {
|
| 58 |
+
answerQuestion,
|
| 59 |
+
decideApproval,
|
| 60 |
+
dismissQuestion,
|
| 61 |
+
type QuestionAnswerWire,
|
| 62 |
+
} from '../interactions/api';
|
| 63 |
+
import type { SearchHit } from '../search/api';
|
| 64 |
+
import { ChatChannel } from '../transcript/channel';
|
| 65 |
+
import {
|
| 66 |
+
EMPTY_CHAT_STATE,
|
| 67 |
+
hasTurnId,
|
| 68 |
+
type ChatState,
|
| 69 |
+
type TimelineEntry,
|
| 70 |
+
} from '../transcript/store';
|
| 71 |
+
import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui';
|
| 72 |
+
import { ChatSearchBar } from './ChatSearchBar';
|
| 73 |
+
|
| 74 |
+
const noopSubscribe = () => () => {};
|
| 75 |
+
|
| 76 |
+
/** Active session id for deeply nested interaction views (approve/answer buttons). */
|
| 77 |
+
const SessionContext = createContext<string>('');
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* A navigation request into the chat timeline (e.g. from a search hit). The
|
| 81 |
+
* channel pages backwards until the turn enters the loaded window, then
|
| 82 |
+
* scrolls it into view and flashes it briefly.
|
| 83 |
+
*/
|
| 84 |
+
export interface ChatJump {
|
| 85 |
+
/** Turn to locate (`t<N>`); omitted = switch session/agent only, no scroll. */
|
| 86 |
+
readonly turnId?: string | undefined;
|
| 87 |
+
/** Step within the turn (`t<N>.<M>`); falls back to the turn card. */
|
| 88 |
+
readonly stepId?: string | undefined;
|
| 89 |
+
/** Changes on every request so re-clicking the same hit re-triggers. */
|
| 90 |
+
readonly nonce: number;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
interface ChatChannelState {
|
| 94 |
+
/** Null until the effect has created the channel (pre-ready / no session). */
|
| 95 |
+
readonly channel: ChatChannel | null;
|
| 96 |
+
readonly state: ChatState;
|
| 97 |
+
/** Records every step that built the store (audit panel data source). */
|
| 98 |
+
readonly trail: AuditTrail | null;
|
| 99 |
+
/** True once the initial REST page load succeeded. */
|
| 100 |
+
readonly loaded: boolean;
|
| 101 |
+
/** Set when the initial/refresh load failed. */
|
| 102 |
+
readonly loadError: unknown;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* Owns the channel (store + REST + WS) for one (sessionId, agentId) pair.
|
| 107 |
+
*/
|
| 108 |
+
function useChatChannel(
|
| 109 |
+
sessionId: string | null,
|
| 110 |
+
agentId: string,
|
| 111 |
+
ready: boolean,
|
| 112 |
+
captureAnchor: () => void,
|
| 113 |
+
): ChatChannelState {
|
| 114 |
+
const { baseUrl, config } = useConnection();
|
| 115 |
+
const token = config.token.trim();
|
| 116 |
+
const [channel, setChannel] = useState<ChatChannel | null>(null);
|
| 117 |
+
const [loaded, setLoaded] = useState(false);
|
| 118 |
+
const [loadError, setLoadError] = useState<unknown>(null);
|
| 119 |
+
|
| 120 |
+
useEffect(() => {
|
| 121 |
+
if (!ready || sessionId === null) return;
|
| 122 |
+
const authToken = token === '' ? undefined : token;
|
| 123 |
+
const next = new ChatChannel({
|
| 124 |
+
baseUrl,
|
| 125 |
+
token: authToken,
|
| 126 |
+
sessionId,
|
| 127 |
+
agentId,
|
| 128 |
+
onWillReplace: captureAnchor,
|
| 129 |
+
onLoaded: () => {
|
| 130 |
+
setLoaded(true);
|
| 131 |
+
setLoadError(null);
|
| 132 |
+
},
|
| 133 |
+
onLoadError: (error) => {
|
| 134 |
+
setLoadError(error);
|
| 135 |
+
},
|
| 136 |
+
});
|
| 137 |
+
setChannel(next);
|
| 138 |
+
setLoaded(false);
|
| 139 |
+
setLoadError(null);
|
| 140 |
+
next.start();
|
| 141 |
+
return () => {
|
| 142 |
+
next.close();
|
| 143 |
+
setChannel(null);
|
| 144 |
+
};
|
| 145 |
+
}, [sessionId, agentId, ready, baseUrl, token, captureAnchor]);
|
| 146 |
+
|
| 147 |
+
const state = useSyncExternalStore(
|
| 148 |
+
channel?.store.subscribe ?? noopSubscribe,
|
| 149 |
+
() => channel?.store.getState() ?? EMPTY_CHAT_STATE,
|
| 150 |
+
);
|
| 151 |
+
return {
|
| 152 |
+
channel,
|
| 153 |
+
state,
|
| 154 |
+
trail: channel?.trail ?? null,
|
| 155 |
+
loaded,
|
| 156 |
+
loadError,
|
| 157 |
+
};
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
export function ChatView({
|
| 161 |
+
sessionId,
|
| 162 |
+
agentId,
|
| 163 |
+
ready,
|
| 164 |
+
onTrailChange,
|
| 165 |
+
jump,
|
| 166 |
+
onJumpHandled,
|
| 167 |
+
onOpenSearchHit,
|
| 168 |
+
}: {
|
| 169 |
+
sessionId: string | null;
|
| 170 |
+
agentId: string;
|
| 171 |
+
ready: boolean;
|
| 172 |
+
/** Hands the audit trail of the current channel up to the app shell (the audit panel lives in the right dock, not inside this view). */
|
| 173 |
+
onTrailChange?: (trail: AuditTrail | null) => void;
|
| 174 |
+
/** Pending navigation into the timeline (search result click). */
|
| 175 |
+
jump?: ChatJump | null | undefined;
|
| 176 |
+
/** Called once the jump has been processed (or found un-actionable). */
|
| 177 |
+
onJumpHandled?: (() => void) | undefined;
|
| 178 |
+
/** Hands an in-chat search hit up to the app shell (agent switch + jump). */
|
| 179 |
+
onOpenSearchHit?: ((hit: SearchHit) => void) | undefined;
|
| 180 |
+
}) {
|
| 181 |
+
const { klient } = useConnection();
|
| 182 |
+
const [sendError, setSendError] = useState<unknown>(null);
|
| 183 |
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
| 184 |
+
const [olderError, setOlderError] = useState<unknown>(null);
|
| 185 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 186 |
+
/** Distance from the scroll bottom captured before a prepend (restore anchor). */
|
| 187 |
+
const anchorRef = useRef<number | null>(null);
|
| 188 |
+
/** Whether the viewport was pinned to the bottom before the last update. */
|
| 189 |
+
const stickBottomRef = useRef(true);
|
| 190 |
+
/** The jump target being flashed (cleared on a timer). */
|
| 191 |
+
const [flash, setFlash] = useState<{ turnId: string; stepId?: string | undefined } | null>(null);
|
| 192 |
+
|
| 193 |
+
const captureAnchor = useCallback(() => {
|
| 194 |
+
const el = scrollRef.current;
|
| 195 |
+
if (el !== null) anchorRef.current = el.scrollHeight - el.scrollTop;
|
| 196 |
+
}, []);
|
| 197 |
+
|
| 198 |
+
const { channel, state, trail, loaded, loadError } = useChatChannel(
|
| 199 |
+
sessionId,
|
| 200 |
+
agentId,
|
| 201 |
+
ready,
|
| 202 |
+
captureAnchor,
|
| 203 |
+
);
|
| 204 |
+
const entries = state.entries;
|
| 205 |
+
|
| 206 |
+
// The audit panel is rendered by the app shell's right dock; report the
|
| 207 |
+
// trail (null while no channel exists) so it can subscribe to it there.
|
| 208 |
+
useEffect(() => {
|
| 209 |
+
onTrailChange?.(trail);
|
| 210 |
+
}, [onTrailChange, trail]);
|
| 211 |
+
|
| 212 |
+
// Jump navigation (search result click): once the channel has loaded, page
|
| 213 |
+
// backwards until the target turn enters the window, then scroll to the
|
| 214 |
+
// step (or the turn card) and flash it briefly. A turn that never appears
|
| 215 |
+
// (cut by an undo) degrades to no scroll.
|
| 216 |
+
useEffect(() => {
|
| 217 |
+
if (jump === null || jump === undefined || !loaded || channel === null || sessionId === null) {
|
| 218 |
+
return;
|
| 219 |
+
}
|
| 220 |
+
if (jump.turnId === undefined) {
|
| 221 |
+
onJumpHandled?.();
|
| 222 |
+
return;
|
| 223 |
+
}
|
| 224 |
+
let cancelled = false;
|
| 225 |
+
const isCancelled = (): boolean => cancelled;
|
| 226 |
+
const turnId = jump.turnId;
|
| 227 |
+
const stepId = jump.stepId;
|
| 228 |
+
void (async () => {
|
| 229 |
+
stickBottomRef.current = false;
|
| 230 |
+
const store = channel.store;
|
| 231 |
+
try {
|
| 232 |
+
while (
|
| 233 |
+
!hasTurnId(store.getState().entries, turnId) &&
|
| 234 |
+
store.getState().hasMoreOlder &&
|
| 235 |
+
!isCancelled()
|
| 236 |
+
) {
|
| 237 |
+
const before = store.getState().entries.length;
|
| 238 |
+
await channel.loadOlder();
|
| 239 |
+
if (store.getState().entries.length === before) break;
|
| 240 |
+
}
|
| 241 |
+
} catch {
|
| 242 |
+
// A failed older-page load leaves the window as-is; degrade to no scroll.
|
| 243 |
+
}
|
| 244 |
+
if (cancelled) return;
|
| 245 |
+
if (!hasTurnId(store.getState().entries, turnId)) {
|
| 246 |
+
onJumpHandled?.();
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
setFlash({ turnId, stepId });
|
| 250 |
+
// The prepend renders asynchronously; wait two frames before scrolling.
|
| 251 |
+
requestAnimationFrame(() => {
|
| 252 |
+
requestAnimationFrame(() => {
|
| 253 |
+
if (cancelled) return;
|
| 254 |
+
const root = scrollRef.current;
|
| 255 |
+
const stepEl =
|
| 256 |
+
stepId !== undefined
|
| 257 |
+
? root?.querySelector(`[data-step-id="${CSS.escape(stepId)}"]`)
|
| 258 |
+
: undefined;
|
| 259 |
+
const target = stepEl ?? root?.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`);
|
| 260 |
+
target?.scrollIntoView({ block: 'start' });
|
| 261 |
+
});
|
| 262 |
+
});
|
| 263 |
+
onJumpHandled?.();
|
| 264 |
+
})();
|
| 265 |
+
return () => {
|
| 266 |
+
cancelled = true;
|
| 267 |
+
};
|
| 268 |
+
}, [jump, loaded, channel, sessionId, onJumpHandled]);
|
| 269 |
+
|
| 270 |
+
// The flash highlight clears itself after a short moment.
|
| 271 |
+
useEffect(() => {
|
| 272 |
+
if (flash === null) return;
|
| 273 |
+
const timer = setTimeout(() => setFlash(null), 2400);
|
| 274 |
+
return () => clearTimeout(timer);
|
| 275 |
+
}, [flash]);
|
| 276 |
+
|
| 277 |
+
useLayoutEffect(() => {
|
| 278 |
+
const el = scrollRef.current;
|
| 279 |
+
if (el === null) return;
|
| 280 |
+
if (anchorRef.current !== null) {
|
| 281 |
+
el.scrollTop = el.scrollHeight - anchorRef.current;
|
| 282 |
+
anchorRef.current = null;
|
| 283 |
+
return;
|
| 284 |
+
}
|
| 285 |
+
if (stickBottomRef.current) el.scrollTop = el.scrollHeight;
|
| 286 |
+
}, [entries]);
|
| 287 |
+
|
| 288 |
+
const onScroll = () => {
|
| 289 |
+
const el = scrollRef.current;
|
| 290 |
+
if (el === null) return;
|
| 291 |
+
stickBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
| 292 |
+
};
|
| 293 |
+
|
| 294 |
+
const loadOlder = async () => {
|
| 295 |
+
if (channel === null || loadingOlder) return;
|
| 296 |
+
captureAnchor();
|
| 297 |
+
setLoadingOlder(true);
|
| 298 |
+
setOlderError(null);
|
| 299 |
+
try {
|
| 300 |
+
await channel.loadOlder();
|
| 301 |
+
} catch (error) {
|
| 302 |
+
anchorRef.current = null;
|
| 303 |
+
setOlderError(error);
|
| 304 |
+
trail?.recordEvent(
|
| 305 |
+
'older-error',
|
| 306 |
+
error instanceof Error ? error.message : String(error),
|
| 307 |
+
channel.store.getState(),
|
| 308 |
+
);
|
| 309 |
+
} finally {
|
| 310 |
+
setLoadingOlder(false);
|
| 311 |
+
}
|
| 312 |
+
};
|
| 313 |
+
|
| 314 |
+
// Auto-paging: the top sentinel auto-loads the previous REST page when it
|
| 315 |
+
// approaches the viewport (paused while a previous load failed — the retry
|
| 316 |
+
// button re-arms it). This replaces any manual "load earlier" action.
|
| 317 |
+
const loadOlderRef = useRef(loadOlder);
|
| 318 |
+
loadOlderRef.current = loadOlder;
|
| 319 |
+
const topSentinelRef = useRef<HTMLDivElement>(null);
|
| 320 |
+
const hasMoreOlder = state.hasMoreOlder;
|
| 321 |
+
useEffect(() => {
|
| 322 |
+
const sentinel = topSentinelRef.current;
|
| 323 |
+
const root = scrollRef.current;
|
| 324 |
+
if (sentinel === null || root === null || olderError !== null) return;
|
| 325 |
+
const observer = new IntersectionObserver(
|
| 326 |
+
(observed) => {
|
| 327 |
+
if (observed.some((entry) => entry.isIntersecting)) void loadOlderRef.current();
|
| 328 |
+
},
|
| 329 |
+
{ root, rootMargin: '400px 0px 0px 0px' },
|
| 330 |
+
);
|
| 331 |
+
observer.observe(sentinel);
|
| 332 |
+
return () => {
|
| 333 |
+
observer.disconnect();
|
| 334 |
+
};
|
| 335 |
+
}, [hasMoreOlder, loaded, olderError, loadingOlder]);
|
| 336 |
+
|
| 337 |
+
const running =
|
| 338 |
+
(state.sessionState !== undefined && state.sessionState.status !== 'idle') ||
|
| 339 |
+
isAnyTurnRunning(entries);
|
| 340 |
+
const pendingCount = [...state.interactions.values()].filter(
|
| 341 |
+
(interaction) => interaction.status === 'pending',
|
| 342 |
+
).length;
|
| 343 |
+
|
| 344 |
+
// Interactions render inline at their anchor tool call; entities without
|
| 345 |
+
// an anchor (or whose anchor is outside the loaded window) collect here
|
| 346 |
+
// and render floating at the bottom. Unanchored tasks (no tool call
|
| 347 |
+
// references them, e.g. shell-command tasks) do the same.
|
| 348 |
+
const anchoredToolCallIds = useMemo(() => collectToolCallIds(entries), [entries]);
|
| 349 |
+
const unanchoredInteractions = [...state.interactions.values()].filter(
|
| 350 |
+
(interaction) =>
|
| 351 |
+
interaction.tool_call_id === undefined || !anchoredToolCallIds.has(interaction.tool_call_id),
|
| 352 |
+
);
|
| 353 |
+
const anchoredTaskIds = useMemo(() => collectTaskIds(entries), [entries]);
|
| 354 |
+
const unanchoredTasks = [...state.tasks.values()].filter(
|
| 355 |
+
(task) => !anchoredTaskIds.has(task.task_id),
|
| 356 |
+
);
|
| 357 |
+
const latestTodo = latestTodoOf(state.todos);
|
| 358 |
+
|
| 359 |
+
const cancel = async () => {
|
| 360 |
+
if (sessionId === null) return;
|
| 361 |
+
try {
|
| 362 |
+
await klient.session(sessionId).agent(agentId).service(IAgentLoopService).cancel(undefined);
|
| 363 |
+
trail?.recordEvent('cancel', undefined, state);
|
| 364 |
+
} catch (error) {
|
| 365 |
+
setSendError(error);
|
| 366 |
+
}
|
| 367 |
+
};
|
| 368 |
+
|
| 369 |
+
if (sessionId === null) {
|
| 370 |
+
return (
|
| 371 |
+
<div className="flex flex-1 items-center justify-center text-sm text-neutral-600">
|
| 372 |
+
Select a session on the left to open its conversation.
|
| 373 |
+
</div>
|
| 374 |
+
);
|
| 375 |
+
}
|
| 376 |
+
if (!ready) {
|
| 377 |
+
return (
|
| 378 |
+
<div className="flex flex-1 items-center justify-center text-sm text-neutral-600">
|
| 379 |
+
Loading session…
|
| 380 |
+
</div>
|
| 381 |
+
);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
return (
|
| 385 |
+
<SessionContext.Provider value={sessionId}>
|
| 386 |
+
<div className="flex min-w-0 flex-1 flex-col">
|
| 387 |
+
<div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
|
| 388 |
+
<span className="font-mono text-[11px] text-neutral-400">{sessionId}</span>
|
| 389 |
+
<Badge tone="sky">agent: {agentId}</Badge>
|
| 390 |
+
{running ? <Badge tone="amber">turn running</Badge> : <Badge tone="green">idle</Badge>}
|
| 391 |
+
{pendingCount > 0 ? <Badge tone="amber">{pendingCount} pending</Badge> : null}
|
| 392 |
+
{state.sessionState !== undefined ? (
|
| 393 |
+
<SessionStateBadges sessionState={state.sessionState} />
|
| 394 |
+
) : null}
|
| 395 |
+
</div>
|
| 396 |
+
|
| 397 |
+
<ChatSearchBar sessionId={sessionId} onOpenHit={onOpenSearchHit} />
|
| 398 |
+
|
| 399 |
+
<div className="flex-1 overflow-y-auto px-4 py-3" ref={scrollRef} onScroll={onScroll}>
|
| 400 |
+
{state.hasMoreOlder ? (
|
| 401 |
+
<div ref={topSentinelRef} className="mb-3 flex justify-center">
|
| 402 |
+
<span className="text-[11px] text-neutral-600">
|
| 403 |
+
{loadingOlder ? 'Loading earlier turns…' : ''}
|
| 404 |
+
</span>
|
| 405 |
+
</div>
|
| 406 |
+
) : null}
|
| 407 |
+
{olderError !== null ? (
|
| 408 |
+
<div className="mb-2">
|
| 409 |
+
<ErrorLine error={olderError} />
|
| 410 |
+
<div className="mt-1 flex justify-center">
|
| 411 |
+
<ActionButton
|
| 412 |
+
onClick={() => {
|
| 413 |
+
setOlderError(null);
|
| 414 |
+
void loadOlder();
|
| 415 |
+
}}
|
| 416 |
+
>
|
| 417 |
+
Retry loading earlier turns
|
| 418 |
+
</ActionButton>
|
| 419 |
+
</div>
|
| 420 |
+
</div>
|
| 421 |
+
) : null}
|
| 422 |
+
{loadError !== null ? (
|
| 423 |
+
<div className="mb-2">
|
| 424 |
+
<ErrorLine error={loadError} />
|
| 425 |
+
<div className="mt-1 text-[11px] text-neutral-600">
|
| 426 |
+
Failed to load the session history ��� the server may be too old to expose the
|
| 427 |
+
history API.
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
) : null}
|
| 431 |
+
{entries.length === 0 && loadError === null ? (
|
| 432 |
+
<div className="text-[12px] text-neutral-600 italic">
|
| 433 |
+
{loaded ? 'Empty transcript.' : 'Loading transcript…'}
|
| 434 |
+
</div>
|
| 435 |
+
) : null}
|
| 436 |
+
{latestTodo !== undefined && latestTodo.items.length > 0 ? (
|
| 437 |
+
<TodoCard todo={latestTodo} />
|
| 438 |
+
) : null}
|
| 439 |
+
<Timeline items={entries} interactions={state.interactions} tasks={state.tasks} flash={flash} />
|
| 440 |
+
{unanchoredInteractions.map((interaction) => (
|
| 441 |
+
<InteractionEntityView key={interaction.interaction_id} interaction={interaction} />
|
| 442 |
+
))}
|
| 443 |
+
{unanchoredTasks.map((task) => (
|
| 444 |
+
<TaskCard key={task.task_id} task={task} />
|
| 445 |
+
))}
|
| 446 |
+
</div>
|
| 447 |
+
|
| 448 |
+
<div className="border-t border-neutral-800 p-3">
|
| 449 |
+
{sendError !== null ? (
|
| 450 |
+
<div className="mb-2">
|
| 451 |
+
<ErrorLine error={sendError} />
|
| 452 |
+
</div>
|
| 453 |
+
) : null}
|
| 454 |
+
<div className="flex justify-end">
|
| 455 |
+
<ActionButton onClick={() => void cancel()} danger disabled={!running}>
|
| 456 |
+
Cancel
|
| 457 |
+
</ActionButton>
|
| 458 |
+
</div>
|
| 459 |
+
</div>
|
| 460 |
+
</div>
|
| 461 |
+
</SessionContext.Provider>
|
| 462 |
+
);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
// ---------------------------------------------------------------- timeline
|
| 466 |
+
|
| 467 |
+
type RenderItem =
|
| 468 |
+
| {
|
| 469 |
+
readonly kind: 'group';
|
| 470 |
+
readonly turnId: string;
|
| 471 |
+
readonly turn: TurnMessage | undefined;
|
| 472 |
+
readonly items: readonly TimelineEntry[];
|
| 473 |
+
}
|
| 474 |
+
| { readonly kind: 'system'; readonly key: string; readonly message: SystemMessage };
|
| 475 |
+
|
| 476 |
+
/** Pseudo-turn grouping queued (unread) user messages, which carry no turn_id yet. */
|
| 477 |
+
const QUEUED_TURN_ID = '$queued';
|
| 478 |
+
|
| 479 |
+
function groupTimeline(entries: readonly TimelineEntry[]): RenderItem[] {
|
| 480 |
+
interface GroupDraft {
|
| 481 |
+
turn?: TurnMessage;
|
| 482 |
+
items: TimelineEntry[];
|
| 483 |
+
}
|
| 484 |
+
const drafts = new Map<string, GroupDraft>();
|
| 485 |
+
const order: (
|
| 486 |
+
| { kind: 'group'; turnId: string }
|
| 487 |
+
| { kind: 'system'; key: string; message: SystemMessage }
|
| 488 |
+
)[] = [];
|
| 489 |
+
for (const entry of entries) {
|
| 490 |
+
const message = entry.message;
|
| 491 |
+
if (message.type === 'system') {
|
| 492 |
+
order.push({ kind: 'system', key: entry.key, message });
|
| 493 |
+
continue;
|
| 494 |
+
}
|
| 495 |
+
const turnId = message.turn_id ?? QUEUED_TURN_ID;
|
| 496 |
+
let draft = drafts.get(turnId);
|
| 497 |
+
if (draft === undefined) {
|
| 498 |
+
draft = { items: [] };
|
| 499 |
+
drafts.set(turnId, draft);
|
| 500 |
+
order.push({ kind: 'group', turnId });
|
| 501 |
+
}
|
| 502 |
+
if (message.type === 'turn') draft.turn = message;
|
| 503 |
+
draft.items.push(entry);
|
| 504 |
+
}
|
| 505 |
+
return order.map((item) =>
|
| 506 |
+
item.kind === 'system'
|
| 507 |
+
? item
|
| 508 |
+
: {
|
| 509 |
+
kind: 'group',
|
| 510 |
+
turnId: item.turnId,
|
| 511 |
+
turn: drafts.get(item.turnId)?.turn,
|
| 512 |
+
items: drafts.get(item.turnId)?.items ?? [],
|
| 513 |
+
},
|
| 514 |
+
);
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
function Timeline({
|
| 518 |
+
items,
|
| 519 |
+
interactions,
|
| 520 |
+
tasks,
|
| 521 |
+
flash,
|
| 522 |
+
}: {
|
| 523 |
+
items: readonly TimelineEntry[];
|
| 524 |
+
interactions: ReadonlyMap<string, InteractionMessage>;
|
| 525 |
+
tasks: ReadonlyMap<string, TaskMessage>;
|
| 526 |
+
flash?: { turnId: string; stepId?: string | undefined } | null | undefined;
|
| 527 |
+
}) {
|
| 528 |
+
const renderItems = useMemo(() => groupTimeline(items), [items]);
|
| 529 |
+
return (
|
| 530 |
+
<>
|
| 531 |
+
{renderItems.map((item) =>
|
| 532 |
+
item.kind === 'system' ? (
|
| 533 |
+
// Native virtual screen: the browser skips layout/paint for
|
| 534 |
+
// off-screen items and remembers their last rendered size.
|
| 535 |
+
<div
|
| 536 |
+
key={item.key}
|
| 537 |
+
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 60px' }}
|
| 538 |
+
>
|
| 539 |
+
<SystemMarkerView message={item.message} />
|
| 540 |
+
</div>
|
| 541 |
+
) : (
|
| 542 |
+
<div
|
| 543 |
+
key={`turn:${item.turnId}`}
|
| 544 |
+
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 200px' }}
|
| 545 |
+
>
|
| 546 |
+
<TurnGroupView
|
| 547 |
+
turnId={item.turnId}
|
| 548 |
+
turn={item.turn}
|
| 549 |
+
items={item.items}
|
| 550 |
+
interactions={interactions}
|
| 551 |
+
tasks={tasks}
|
| 552 |
+
flash={flash}
|
| 553 |
+
/>
|
| 554 |
+
</div>
|
| 555 |
+
),
|
| 556 |
+
)}
|
| 557 |
+
</>
|
| 558 |
+
);
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
function isAnyTurnRunning(entries: readonly TimelineEntry[]): boolean {
|
| 562 |
+
return entries.some(
|
| 563 |
+
(entry) => entry.message.type === 'turn' && entry.message.status === 'running',
|
| 564 |
+
);
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
function collectToolCallIds(entries: readonly TimelineEntry[]): Set<string> {
|
| 568 |
+
const ids = new Set<string>();
|
| 569 |
+
for (const entry of entries) {
|
| 570 |
+
if (entry.message.type === 'tool_call') ids.add(entry.message.tool_call_id);
|
| 571 |
+
}
|
| 572 |
+
return ids;
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
function collectTaskIds(entries: readonly TimelineEntry[]): Set<string> {
|
| 576 |
+
const ids = new Set<string>();
|
| 577 |
+
for (const entry of entries) {
|
| 578 |
+
if (entry.message.type === 'tool_call' && entry.message.task_id !== undefined) {
|
| 579 |
+
ids.add(entry.message.task_id);
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
return ids;
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
function latestTodoOf(todos: ReadonlyMap<string, TodoMessage>): TodoMessage | undefined {
|
| 586 |
+
let latest: TodoMessage | undefined;
|
| 587 |
+
for (const todo of todos.values()) {
|
| 588 |
+
if (latest === undefined || todo.timestamp > latest.timestamp) latest = todo;
|
| 589 |
+
}
|
| 590 |
+
return latest;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
// ---------------------------------------------------------------- turn group
|
| 594 |
+
|
| 595 |
+
function TurnGroupView({
|
| 596 |
+
turnId,
|
| 597 |
+
turn,
|
| 598 |
+
items,
|
| 599 |
+
interactions,
|
| 600 |
+
tasks,
|
| 601 |
+
flash,
|
| 602 |
+
}: {
|
| 603 |
+
turnId: string;
|
| 604 |
+
turn: TurnMessage | undefined;
|
| 605 |
+
items: readonly TimelineEntry[];
|
| 606 |
+
interactions: ReadonlyMap<string, InteractionMessage>;
|
| 607 |
+
tasks: ReadonlyMap<string, TaskMessage>;
|
| 608 |
+
flash?: { turnId: string; stepId?: string | undefined } | null | undefined;
|
| 609 |
+
}) {
|
| 610 |
+
const turnFlashed = flash?.turnId === turnId && flash.stepId === undefined;
|
| 611 |
+
const queued = turnId === QUEUED_TURN_ID;
|
| 612 |
+
return (
|
| 613 |
+
<div
|
| 614 |
+
data-turn-id={turnId}
|
| 615 |
+
className={`mb-3 rounded-lg border bg-neutral-900/30 ${
|
| 616 |
+
turnFlashed ? 'border-sky-600' : 'border-neutral-800'
|
| 617 |
+
}`}
|
| 618 |
+
>
|
| 619 |
+
<div className="flex items-center gap-2 border-b border-neutral-800/60 px-3 py-1.5">
|
| 620 |
+
{queued ? (
|
| 621 |
+
<Badge tone="amber">queued</Badge>
|
| 622 |
+
) : (
|
| 623 |
+
<span className="font-mono text-[10px] text-neutral-500">{turnId}</span>
|
| 624 |
+
)}
|
| 625 |
+
{turn !== undefined ? (
|
| 626 |
+
<>
|
| 627 |
+
<Badge tone={turn.origin.kind === 'user' ? 'sky' : 'neutral'}>{turn.origin.kind}</Badge>
|
| 628 |
+
<Badge tone={turn.status === 'running' ? 'amber' : 'green'}>{turn.status}</Badge>
|
| 629 |
+
{turn.started_at !== undefined ? (
|
| 630 |
+
<span className="text-[10px] text-neutral-600">
|
| 631 |
+
{relTime(Date.parse(turn.started_at))}
|
| 632 |
+
</span>
|
| 633 |
+
) : null}
|
| 634 |
+
{turn.usage !== undefined ? (
|
| 635 |
+
<span className="ml-auto text-[10px] text-neutral-600">
|
| 636 |
+
{turnUsageText(turn.usage)}
|
| 637 |
+
</span>
|
| 638 |
+
) : null}
|
| 639 |
+
</>
|
| 640 |
+
) : queued ? (
|
| 641 |
+
<span className="text-[10px] text-neutral-600 italic">not consumed into a turn yet</span>
|
| 642 |
+
) : (
|
| 643 |
+
<span className="text-[10px] text-neutral-700 italic">turn header outside the window</span>
|
| 644 |
+
)}
|
| 645 |
+
</div>
|
| 646 |
+
<div className="px-3 py-2">
|
| 647 |
+
{turn?.attachment_ids !== undefined && turn.attachment_ids.length > 0 ? (
|
| 648 |
+
<AttachmentChips ids={turn.attachment_ids} />
|
| 649 |
+
) : null}
|
| 650 |
+
{items.map((entry) => (
|
| 651 |
+
<TimelineEntryView
|
| 652 |
+
key={entry.key}
|
| 653 |
+
entry={entry}
|
| 654 |
+
interactions={interactions}
|
| 655 |
+
tasks={tasks}
|
| 656 |
+
flash={flash}
|
| 657 |
+
/>
|
| 658 |
+
))}
|
| 659 |
+
</div>
|
| 660 |
+
</div>
|
| 661 |
+
);
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
function turnUsageText(usage: NonNullable<TurnMessage['usage']>): string {
|
| 665 |
+
const parts: string[] = [];
|
| 666 |
+
if (usage.input_tokens !== undefined) parts.push(`in ${usage.input_tokens}`);
|
| 667 |
+
if (usage.output_tokens !== undefined) parts.push(`out ${usage.output_tokens}`);
|
| 668 |
+
if (usage.cached_tokens !== undefined) parts.push(`cached ${usage.cached_tokens}`);
|
| 669 |
+
if (usage.cost !== undefined) parts.push(`$${usage.cost.toFixed(4)}`);
|
| 670 |
+
return parts.join(' / ');
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
function TimelineEntryView({
|
| 674 |
+
entry,
|
| 675 |
+
interactions,
|
| 676 |
+
tasks,
|
| 677 |
+
flash,
|
| 678 |
+
}: {
|
| 679 |
+
entry: TimelineEntry;
|
| 680 |
+
interactions: ReadonlyMap<string, InteractionMessage>;
|
| 681 |
+
tasks: ReadonlyMap<string, TaskMessage>;
|
| 682 |
+
flash?: { turnId: string; stepId?: string | undefined } | null | undefined;
|
| 683 |
+
}) {
|
| 684 |
+
const message = entry.message;
|
| 685 |
+
switch (message.type) {
|
| 686 |
+
case 'turn':
|
| 687 |
+
return null;
|
| 688 |
+
case 'step':
|
| 689 |
+
return <StepRow step={message} flashed={flash?.stepId === message.step_id} />;
|
| 690 |
+
case 'user':
|
| 691 |
+
return <UserMessageView message={message} />;
|
| 692 |
+
case 'assistant':
|
| 693 |
+
return <AssistantMessageView message={message} />;
|
| 694 |
+
case 'thinking':
|
| 695 |
+
return <ThinkingMessageView message={message} />;
|
| 696 |
+
case 'tool_call':
|
| 697 |
+
return <ToolCallView call={message} interactions={interactions} tasks={tasks} />;
|
| 698 |
+
case 'system':
|
| 699 |
+
return <SystemMarkerView message={message} />;
|
| 700 |
+
}
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
function StepRow({ step, flashed }: { step: StepMessage; flashed: boolean }) {
|
| 704 |
+
return (
|
| 705 |
+
<div
|
| 706 |
+
data-step-id={step.step_id}
|
| 707 |
+
className={`mb-2 flex flex-wrap items-center gap-2 rounded px-1 py-0.5 text-[10px] text-neutral-600 ${
|
| 708 |
+
flashed ? 'bg-sky-900/20' : ''
|
| 709 |
+
}`}
|
| 710 |
+
>
|
| 711 |
+
<span className="font-mono">{step.step_id}</span>
|
| 712 |
+
<Badge
|
| 713 |
+
tone={
|
| 714 |
+
step.status === 'failed'
|
| 715 |
+
? 'red'
|
| 716 |
+
: step.status === 'running'
|
| 717 |
+
? 'amber'
|
| 718 |
+
: step.status === 'interrupted'
|
| 719 |
+
? 'neutral'
|
| 720 |
+
: 'green'
|
| 721 |
+
}
|
| 722 |
+
>
|
| 723 |
+
{step.status}
|
| 724 |
+
</Badge>
|
| 725 |
+
{step.retry !== undefined ? (
|
| 726 |
+
<Badge tone="red">
|
| 727 |
+
retry {step.retry.failed_attempt}→{step.retry.next_attempt}/{step.retry.max_attempts}:{' '}
|
| 728 |
+
{step.retry.error_name}
|
| 729 |
+
</Badge>
|
| 730 |
+
) : null}
|
| 731 |
+
{step.finish_reason !== undefined ? <span>finish: {step.finish_reason}</span> : null}
|
| 732 |
+
{step.usage !== undefined ? (
|
| 733 |
+
<span>
|
| 734 |
+
in {step.usage.input_other + step.usage.input_cache_read + step.usage.input_cache_creation}{' '}
|
| 735 |
+
/ out {step.usage.output}
|
| 736 |
+
</span>
|
| 737 |
+
) : null}
|
| 738 |
+
{step.end_reason !== undefined ? <span className="italic">{step.end_reason}</span> : null}
|
| 739 |
+
{step.end_message !== undefined ? <span className="italic">{step.end_message}</span> : null}
|
| 740 |
+
</div>
|
| 741 |
+
);
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
// ---------------------------------------------------------------- messages
|
| 745 |
+
|
| 746 |
+
function UserMessageView({ message }: { message: UserMessage }) {
|
| 747 |
+
const isUserInput = message.origin === undefined || message.origin.kind === 'user';
|
| 748 |
+
return (
|
| 749 |
+
<div className="mb-2">
|
| 750 |
+
<div className="mb-0.5 flex items-center gap-2 text-[10px] text-neutral-600">
|
| 751 |
+
<span className="font-mono">{message.message_id}</span>
|
| 752 |
+
{message.origin !== undefined && message.origin.kind !== 'user' ? (
|
| 753 |
+
<Badge tone="neutral">{userOriginLabel(message.origin)}</Badge>
|
| 754 |
+
) : null}
|
| 755 |
+
{message.status === 'unread' ? <span className="italic">queued</span> : null}
|
| 756 |
+
</div>
|
| 757 |
+
{isUserInput ? (
|
| 758 |
+
<div className="flex justify-end">
|
| 759 |
+
<div className="max-w-[80%] whitespace-pre-wrap rounded-lg bg-sky-900/40 px-3 py-2 text-[13px] text-neutral-100">
|
| 760 |
+
<UserContentParts parts={message.text} />
|
| 761 |
+
</div>
|
| 762 |
+
</div>
|
| 763 |
+
) : (
|
| 764 |
+
<div className="whitespace-pre-wrap rounded-lg border border-neutral-800 px-3 py-2 text-[12px] text-neutral-400">
|
| 765 |
+
<UserContentParts parts={message.text} />
|
| 766 |
+
</div>
|
| 767 |
+
)}
|
| 768 |
+
{message.attachment_ids !== undefined && message.attachment_ids.length > 0 ? (
|
| 769 |
+
<AttachmentChips ids={message.attachment_ids} />
|
| 770 |
+
) : null}
|
| 771 |
+
{message.skill_activations !== undefined && message.skill_activations.length > 0 ? (
|
| 772 |
+
<div className="mt-1 flex flex-wrap gap-1">
|
| 773 |
+
{message.skill_activations.map((skill) => (
|
| 774 |
+
<Badge key={skill.skill_name} tone="violet">
|
| 775 |
+
skill: {skill.skill_name}
|
| 776 |
+
</Badge>
|
| 777 |
+
))}
|
| 778 |
+
</div>
|
| 779 |
+
) : null}
|
| 780 |
+
</div>
|
| 781 |
+
);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
function userOriginLabel(origin: Exclude<UserMessage['origin'], undefined>): string {
|
| 785 |
+
switch (origin.kind) {
|
| 786 |
+
case 'user':
|
| 787 |
+
return 'user';
|
| 788 |
+
case 'cron':
|
| 789 |
+
return `cron ${origin.cron_id ?? ''}`.trim();
|
| 790 |
+
case 'task':
|
| 791 |
+
return `task: ${origin.title}`;
|
| 792 |
+
case 'skill':
|
| 793 |
+
return `skill: ${origin.skill_name}`;
|
| 794 |
+
}
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
function UserContentParts({ parts }: { parts: readonly ContentPart[] }) {
|
| 798 |
+
const text = parts
|
| 799 |
+
.filter((part) => part.type === 'text' || part.type === 'think')
|
| 800 |
+
.map((part) => part.text)
|
| 801 |
+
.join('\n');
|
| 802 |
+
const media = parts.filter(
|
| 803 |
+
(part) => part.type === 'image' || part.type === 'audio' || part.type === 'video',
|
| 804 |
+
);
|
| 805 |
+
return (
|
| 806 |
+
<>
|
| 807 |
+
{text}
|
| 808 |
+
{media.length > 0 ? (
|
| 809 |
+
<span className="mt-1 flex flex-wrap gap-1">
|
| 810 |
+
{media.map((part, index) => (
|
| 811 |
+
<span
|
| 812 |
+
key={index}
|
| 813 |
+
title={part.text}
|
| 814 |
+
className="rounded border border-neutral-700 bg-neutral-900 px-1.5 py-0.5 text-[10px] text-neutral-400"
|
| 815 |
+
>
|
| 816 |
+
{part.type}: {mediaPartLabel(part)}
|
| 817 |
+
</span>
|
| 818 |
+
))}
|
| 819 |
+
</span>
|
| 820 |
+
) : null}
|
| 821 |
+
</>
|
| 822 |
+
);
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
function mediaPartLabel(part: ContentPart): string {
|
| 826 |
+
const name = part.meta['name'];
|
| 827 |
+
if (typeof name === 'string' && name !== '') return name;
|
| 828 |
+
const id = part.meta['id'];
|
| 829 |
+
if (typeof id === 'string' && id !== '') return id;
|
| 830 |
+
return part.text;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
function AssistantMessageView({ message }: { message: AssistantMessage }) {
|
| 834 |
+
return (
|
| 835 |
+
<div className="mb-2 max-w-[85%]">
|
| 836 |
+
<div className="whitespace-pre-wrap rounded-lg bg-neutral-800/60 px-3 py-2 text-[13px] text-neutral-100">
|
| 837 |
+
{message.text}
|
| 838 |
+
{message.status === 'streaming' ? <span className="text-neutral-500"> ▍</span> : null}
|
| 839 |
+
</div>
|
| 840 |
+
</div>
|
| 841 |
+
);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
function ThinkingMessageView({ message }: { message: ThinkingMessage }) {
|
| 845 |
+
return (
|
| 846 |
+
<div className="mb-2 max-w-[85%] whitespace-pre-wrap rounded-lg border border-dashed border-neutral-700 px-3 py-2 font-mono text-[11px] text-neutral-500">
|
| 847 |
+
{message.text}
|
| 848 |
+
{message.status === 'streaming' ? <span> ▍</span> : null}
|
| 849 |
+
</div>
|
| 850 |
+
);
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
function AttachmentChips({ ids }: { ids: readonly string[] }) {
|
| 854 |
+
return (
|
| 855 |
+
<div className="mb-2 flex flex-wrap gap-1">
|
| 856 |
+
{ids.map((id) => (
|
| 857 |
+
<span
|
| 858 |
+
key={id}
|
| 859 |
+
className="rounded border border-neutral-700 bg-neutral-900 px-2 py-0.5 text-[10px] text-neutral-400"
|
| 860 |
+
>
|
| 861 |
+
📎 {id}
|
| 862 |
+
</span>
|
| 863 |
+
))}
|
| 864 |
+
</div>
|
| 865 |
+
);
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
// ---------------------------------------------------------------- tool calls
|
| 869 |
+
|
| 870 |
+
function ToolCallView({
|
| 871 |
+
call,
|
| 872 |
+
interactions,
|
| 873 |
+
tasks,
|
| 874 |
+
}: {
|
| 875 |
+
call: ToolCallMessage;
|
| 876 |
+
interactions: ReadonlyMap<string, InteractionMessage>;
|
| 877 |
+
tasks: ReadonlyMap<string, TaskMessage>;
|
| 878 |
+
}) {
|
| 879 |
+
const task = call.task_id !== undefined ? tasks.get(call.task_id) : undefined;
|
| 880 |
+
const linked = [...interactions.values()].filter(
|
| 881 |
+
(interaction) =>
|
| 882 |
+
interaction.interaction_id === call.approval_id ||
|
| 883 |
+
interaction.tool_call_id === call.tool_call_id,
|
| 884 |
+
);
|
| 885 |
+
return (
|
| 886 |
+
<div className="mb-2 max-w-[85%] rounded-lg border border-neutral-800 bg-neutral-900/50 px-3 py-2 font-mono text-[11px]">
|
| 887 |
+
<div className="mb-1 flex flex-wrap items-center gap-2">
|
| 888 |
+
<Badge
|
| 889 |
+
tone={call.status === 'error' ? 'red' : call.status === 'running' ? 'amber' : 'neutral'}
|
| 890 |
+
>
|
| 891 |
+
tool
|
| 892 |
+
</Badge>
|
| 893 |
+
<span className="text-neutral-300">{call.name}</span>
|
| 894 |
+
<span className="text-neutral-600 select-all">{call.tool_call_id}</span>
|
| 895 |
+
{call.view !== undefined && call.view !== call.name ? (
|
| 896 |
+
<span className="text-neutral-600">view: {call.view}</span>
|
| 897 |
+
) : null}
|
| 898 |
+
{call.agent_refs?.map((ref) => (
|
| 899 |
+
<Badge key={ref.agent_id} tone="sky">
|
| 900 |
+
agent: {ref.agent_id}
|
| 901 |
+
</Badge>
|
| 902 |
+
))}
|
| 903 |
+
{task !== undefined ? <span className="text-neutral-600">task: {task.status}</span> : null}
|
| 904 |
+
{call.todo_id !== undefined ? (
|
| 905 |
+
<span className="text-neutral-600">todo: {call.todo_id}</span>
|
| 906 |
+
) : null}
|
| 907 |
+
</div>
|
| 908 |
+
{call.input !== undefined ? (
|
| 909 |
+
typeof call.input === 'string' ? (
|
| 910 |
+
<pre className="max-h-32 overflow-auto whitespace-pre-wrap text-neutral-500">
|
| 911 |
+
{call.input}
|
| 912 |
+
</pre>
|
| 913 |
+
) : (
|
| 914 |
+
<JsonView data={call.input} />
|
| 915 |
+
)
|
| 916 |
+
) : call.input_text !== undefined && call.input_text !== '' ? (
|
| 917 |
+
<pre className="max-h-32 overflow-auto whitespace-pre-wrap text-neutral-500">
|
| 918 |
+
{call.input_text}
|
| 919 |
+
</pre>
|
| 920 |
+
) : null}
|
| 921 |
+
{call.output !== undefined ? (
|
| 922 |
+
typeof call.output === 'string' ? (
|
| 923 |
+
<pre
|
| 924 |
+
className={`max-h-40 overflow-auto whitespace-pre-wrap ${
|
| 925 |
+
call.status === 'error' ? 'text-red-400' : 'text-neutral-400'
|
| 926 |
+
}`}
|
| 927 |
+
>
|
| 928 |
+
{call.output}
|
| 929 |
+
</pre>
|
| 930 |
+
) : (
|
| 931 |
+
<JsonView data={call.output} />
|
| 932 |
+
)
|
| 933 |
+
) : task !== undefined && task.output_tail !== '' ? (
|
| 934 |
+
<pre className="max-h-40 overflow-auto whitespace-pre-wrap text-neutral-400">
|
| 935 |
+
{task.output_tail}
|
| 936 |
+
</pre>
|
| 937 |
+
) : null}
|
| 938 |
+
{call.error !== undefined && call.error !== call.output ? (
|
| 939 |
+
<pre className="max-h-40 overflow-auto whitespace-pre-wrap text-red-400">{call.error}</pre>
|
| 940 |
+
) : null}
|
| 941 |
+
{call.progress !== undefined ? (
|
| 942 |
+
<div className="mt-1 text-neutral-600">
|
| 943 |
+
progress ({call.progress.kind}):{' '}
|
| 944 |
+
{call.progress.text ?? (call.progress.percent !== undefined ? `${call.progress.percent}%` : call.progress.custom_kind ?? '')}
|
| 945 |
+
</div>
|
| 946 |
+
) : null}
|
| 947 |
+
{linked.map((interaction) => (
|
| 948 |
+
<InteractionEntityView key={interaction.interaction_id} interaction={interaction} nested />
|
| 949 |
+
))}
|
| 950 |
+
</div>
|
| 951 |
+
);
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
// ---------------------------------------------------------------- interactions
|
| 955 |
+
|
| 956 |
+
function InteractionEntityView({
|
| 957 |
+
interaction,
|
| 958 |
+
nested,
|
| 959 |
+
}: {
|
| 960 |
+
interaction: InteractionMessage;
|
| 961 |
+
nested?: boolean;
|
| 962 |
+
}) {
|
| 963 |
+
const { baseUrl, config } = useConnection();
|
| 964 |
+
const sessionId = useContext(SessionContext);
|
| 965 |
+
const [busy, setBusy] = useState(false);
|
| 966 |
+
const [respondError, setRespondError] = useState<unknown>(null);
|
| 967 |
+
/** Question answers in progress: question id → selected option labels. */
|
| 968 |
+
const [selections, setSelections] = useState<Readonly<Record<string, readonly string[]>>>({});
|
| 969 |
+
/** Question free-text ("Other") input: question id → draft. */
|
| 970 |
+
const [others, setOthers] = useState<Readonly<Record<string, string>>>({});
|
| 971 |
+
|
| 972 |
+
const pending = interaction.status === 'pending';
|
| 973 |
+
const questionRequest = interaction.kind === 'question' ? interaction.request : undefined;
|
| 974 |
+
const api = { baseUrl, token: config.token, sessionId };
|
| 975 |
+
|
| 976 |
+
const run = (fn: () => Promise<unknown>): void => {
|
| 977 |
+
setBusy(true);
|
| 978 |
+
setRespondError(null);
|
| 979 |
+
void fn()
|
| 980 |
+
.catch((error: unknown) => {
|
| 981 |
+
setRespondError(error);
|
| 982 |
+
})
|
| 983 |
+
.finally(() => {
|
| 984 |
+
setBusy(false);
|
| 985 |
+
});
|
| 986 |
+
};
|
| 987 |
+
|
| 988 |
+
const decide = (decision: 'approved' | 'rejected'): void => {
|
| 989 |
+
run(() => decideApproval(api, interaction.interaction_id, decision));
|
| 990 |
+
};
|
| 991 |
+
|
| 992 |
+
const toggleOption = (question: InteractionQuestionItem, label: string): void => {
|
| 993 |
+
setSelections((prev) => {
|
| 994 |
+
const current = prev[question.id] ?? [];
|
| 995 |
+
const next =
|
| 996 |
+
question.multi_select === true
|
| 997 |
+
? current.includes(label)
|
| 998 |
+
? current.filter((item) => item !== label)
|
| 999 |
+
: [...current, label]
|
| 1000 |
+
: current.includes(label)
|
| 1001 |
+
? []
|
| 1002 |
+
: [label];
|
| 1003 |
+
return { ...prev, [question.id]: next };
|
| 1004 |
+
});
|
| 1005 |
+
};
|
| 1006 |
+
|
| 1007 |
+
const submitAnswers = (): void => {
|
| 1008 |
+
const answers: Record<string, QuestionAnswerWire> = {};
|
| 1009 |
+
for (const question of questionRequest?.questions ?? []) {
|
| 1010 |
+
const selected = selections[question.id] ?? [];
|
| 1011 |
+
const optionIds = selected.flatMap((label) => {
|
| 1012 |
+
const match = question.options.find((option) => option.label === label);
|
| 1013 |
+
return match === undefined ? [] : [match.id];
|
| 1014 |
+
});
|
| 1015 |
+
const other = (others[question.id] ?? '').trim();
|
| 1016 |
+
if (other !== '' && optionIds.length > 0) {
|
| 1017 |
+
answers[question.id] = {
|
| 1018 |
+
kind: 'multi_with_other',
|
| 1019 |
+
option_ids: optionIds,
|
| 1020 |
+
other_text: other,
|
| 1021 |
+
};
|
| 1022 |
+
} else if (other !== '') {
|
| 1023 |
+
answers[question.id] = { kind: 'other', text: other };
|
| 1024 |
+
} else if (optionIds.length > 1 || (question.multi_select === true && optionIds.length > 0)) {
|
| 1025 |
+
answers[question.id] = { kind: 'multi', option_ids: optionIds };
|
| 1026 |
+
} else if (optionIds.length === 1) {
|
| 1027 |
+
answers[question.id] = { kind: 'single', option_id: optionIds[0]! };
|
| 1028 |
+
}
|
| 1029 |
+
}
|
| 1030 |
+
// Mirror the TUI adapter: no answers at all dismisses the question.
|
| 1031 |
+
if (Object.keys(answers).length === 0) {
|
| 1032 |
+
dismiss();
|
| 1033 |
+
return;
|
| 1034 |
+
}
|
| 1035 |
+
run(() => answerQuestion(api, interaction.interaction_id, answers, 'enter'));
|
| 1036 |
+
};
|
| 1037 |
+
|
| 1038 |
+
const dismiss = (): void => {
|
| 1039 |
+
run(() => dismissQuestion(api, interaction.interaction_id));
|
| 1040 |
+
};
|
| 1041 |
+
|
| 1042 |
+
return (
|
| 1043 |
+
<div
|
| 1044 |
+
className={`mb-2 max-w-[85%] rounded-lg border border-amber-900/50 bg-amber-950/20 px-3 py-2 text-[11px] ${
|
| 1045 |
+
nested === true ? 'mt-2 max-w-full' : ''
|
| 1046 |
+
}`}
|
| 1047 |
+
>
|
| 1048 |
+
<div className="mb-1 flex items-center gap-2">
|
| 1049 |
+
<Badge tone={pending ? 'amber' : 'neutral'}>{interaction.kind}</Badge>
|
| 1050 |
+
<span className="text-neutral-400">{interaction.status}</span>
|
| 1051 |
+
<span className="text-neutral-600">tool: {interaction.tool_call_id}</span>
|
| 1052 |
+
</div>
|
| 1053 |
+
{interaction.request !== undefined && questionRequest === undefined ? (
|
| 1054 |
+
<JsonView data={interaction.request} />
|
| 1055 |
+
) : null}
|
| 1056 |
+
{questionRequest !== undefined && !pending ? <JsonView data={questionRequest} /> : null}
|
| 1057 |
+
{interaction.response !== undefined ? <JsonView data={interaction.response} /> : null}
|
| 1058 |
+
{pending && interaction.kind === 'approval' ? (
|
| 1059 |
+
<div className="mt-2 flex gap-2">
|
| 1060 |
+
<ActionButton onClick={() => decide('approved')} disabled={busy}>
|
| 1061 |
+
Approve
|
| 1062 |
+
</ActionButton>
|
| 1063 |
+
<ActionButton onClick={() => decide('rejected')} danger disabled={busy}>
|
| 1064 |
+
Reject
|
| 1065 |
+
</ActionButton>
|
| 1066 |
+
</div>
|
| 1067 |
+
) : null}
|
| 1068 |
+
{pending && questionRequest !== undefined ? (
|
| 1069 |
+
<div className="mt-2">
|
| 1070 |
+
{questionRequest.questions.map((question) => (
|
| 1071 |
+
<div key={question.id} className="mb-2">
|
| 1072 |
+
<div className="text-neutral-300">{question.header ?? question.question}</div>
|
| 1073 |
+
<div className="mt-1 flex flex-wrap gap-1">
|
| 1074 |
+
{question.options.map((option) => {
|
| 1075 |
+
const selected = (selections[question.id] ?? []).includes(option.label);
|
| 1076 |
+
return (
|
| 1077 |
+
<button
|
| 1078 |
+
key={option.id}
|
| 1079 |
+
className={`rounded border px-2 py-0.5 text-[10px] transition-colors disabled:opacity-40 ${
|
| 1080 |
+
selected
|
| 1081 |
+
? 'border-sky-600 bg-sky-900/50 text-sky-200'
|
| 1082 |
+
: 'border-neutral-700 text-neutral-400 hover:bg-neutral-800'
|
| 1083 |
+
}`}
|
| 1084 |
+
title={option.description}
|
| 1085 |
+
disabled={busy}
|
| 1086 |
+
onClick={() => toggleOption(question, option.label)}
|
| 1087 |
+
>
|
| 1088 |
+
{option.label}
|
| 1089 |
+
</button>
|
| 1090 |
+
);
|
| 1091 |
+
})}
|
| 1092 |
+
</div>
|
| 1093 |
+
<input
|
| 1094 |
+
className="mt-1 w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1 text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
| 1095 |
+
placeholder={question.other_label ?? 'Other…'}
|
| 1096 |
+
value={others[question.id] ?? ''}
|
| 1097 |
+
disabled={busy}
|
| 1098 |
+
onChange={(e) => {
|
| 1099 |
+
setOthers((prev) => ({ ...prev, [question.id]: e.target.value }));
|
| 1100 |
+
}}
|
| 1101 |
+
/>
|
| 1102 |
+
</div>
|
| 1103 |
+
))}
|
| 1104 |
+
<div className="flex gap-2">
|
| 1105 |
+
<ActionButton onClick={submitAnswers} disabled={busy}>
|
| 1106 |
+
Answer
|
| 1107 |
+
</ActionButton>
|
| 1108 |
+
<ActionButton onClick={dismiss} danger disabled={busy}>
|
| 1109 |
+
Dismiss
|
| 1110 |
+
</ActionButton>
|
| 1111 |
+
</div>
|
| 1112 |
+
</div>
|
| 1113 |
+
) : null}
|
| 1114 |
+
{respondError !== null ? (
|
| 1115 |
+
<div className="mt-2">
|
| 1116 |
+
<ErrorLine error={respondError} />
|
| 1117 |
+
</div>
|
| 1118 |
+
) : null}
|
| 1119 |
+
</div>
|
| 1120 |
+
);
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
// ---------------------------------------------------------------- state entities
|
| 1124 |
+
|
| 1125 |
+
function SystemMarkerView({ message }: { message: SystemMessage }) {
|
| 1126 |
+
return (
|
| 1127 |
+
<div className="mb-3">
|
| 1128 |
+
<div className="flex items-center gap-2 text-[10px] text-neutral-600">
|
| 1129 |
+
<div className="h-px flex-1 bg-neutral-800" />
|
| 1130 |
+
<span className="font-mono">system({message.subtype})</span>
|
| 1131 |
+
<span className="font-mono text-neutral-700">{message.system_id}</span>
|
| 1132 |
+
{message.at !== undefined ? <span>{relTime(Date.parse(message.at))}</span> : null}
|
| 1133 |
+
<div className="h-px flex-1 bg-neutral-800" />
|
| 1134 |
+
</div>
|
| 1135 |
+
{message.payload !== undefined ? <JsonView data={message.payload} /> : null}
|
| 1136 |
+
</div>
|
| 1137 |
+
);
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
function TaskCard({ task }: { task: TaskMessage }) {
|
| 1141 |
+
const failed =
|
| 1142 |
+
task.status === 'failed' || task.status === 'timed_out' || task.status === 'lost';
|
| 1143 |
+
return (
|
| 1144 |
+
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2 text-[11px]">
|
| 1145 |
+
<div className="flex items-center gap-2">
|
| 1146 |
+
<Badge tone={task.status === 'running' ? 'amber' : failed ? 'red' : 'neutral'}>
|
| 1147 |
+
task: {task.kind}
|
| 1148 |
+
</Badge>
|
| 1149 |
+
<span className="text-neutral-300">{task.description ?? task.task_id}</span>
|
| 1150 |
+
<span className="text-neutral-600">
|
| 1151 |
+
{task.status}
|
| 1152 |
+
{task.detached ? ' (detached)' : ''}
|
| 1153 |
+
</span>
|
| 1154 |
+
{task.child_agent_id !== undefined ? (
|
| 1155 |
+
<Badge tone="sky">agent: {task.child_agent_id}</Badge>
|
| 1156 |
+
) : null}
|
| 1157 |
+
</div>
|
| 1158 |
+
{task.output_tail !== '' ? (
|
| 1159 |
+
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap text-neutral-500">
|
| 1160 |
+
{task.output_tail}
|
| 1161 |
+
</pre>
|
| 1162 |
+
) : null}
|
| 1163 |
+
{task.error !== undefined ? (
|
| 1164 |
+
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap text-red-400">
|
| 1165 |
+
{task.error}
|
| 1166 |
+
</pre>
|
| 1167 |
+
) : null}
|
| 1168 |
+
{task.result_summary !== undefined ? (
|
| 1169 |
+
<div className="mt-1 text-neutral-500">{task.result_summary}</div>
|
| 1170 |
+
) : null}
|
| 1171 |
+
</div>
|
| 1172 |
+
);
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
function TodoCard({ todo }: { todo: TodoMessage }) {
|
| 1176 |
+
return (
|
| 1177 |
+
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2 text-[11px]">
|
| 1178 |
+
<div className="mb-1 text-neutral-500">todo (latest)</div>
|
| 1179 |
+
{todo.items.map((entry, i) => (
|
| 1180 |
+
<div key={i} className="flex gap-2">
|
| 1181 |
+
<span
|
| 1182 |
+
className={
|
| 1183 |
+
entry.status === 'done'
|
| 1184 |
+
? 'text-green-500'
|
| 1185 |
+
: entry.status === 'in_progress'
|
| 1186 |
+
? 'text-sky-400'
|
| 1187 |
+
: 'text-neutral-600'
|
| 1188 |
+
}
|
| 1189 |
+
>
|
| 1190 |
+
{entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'}
|
| 1191 |
+
</span>
|
| 1192 |
+
<span
|
| 1193 |
+
className={entry.status === 'done' ? 'text-neutral-600 line-through' : 'text-neutral-300'}
|
| 1194 |
+
>
|
| 1195 |
+
{entry.title}
|
| 1196 |
+
</span>
|
| 1197 |
+
</div>
|
| 1198 |
+
))}
|
| 1199 |
+
</div>
|
| 1200 |
+
);
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
function SessionStateBadges({ sessionState }: { sessionState: SessionStateMessage }) {
|
| 1204 |
+
return (
|
| 1205 |
+
<>
|
| 1206 |
+
{sessionState.pending_interaction !== undefined &&
|
| 1207 |
+
sessionState.pending_interaction !== 'none' ? (
|
| 1208 |
+
<Badge tone="amber">{sessionState.pending_interaction}</Badge>
|
| 1209 |
+
) : null}
|
| 1210 |
+
{sessionState.model !== undefined ? <Badge tone="neutral">{sessionState.model}</Badge> : null}
|
| 1211 |
+
{sessionState.permission !== undefined ? (
|
| 1212 |
+
<Badge tone="neutral">perm: {sessionState.permission}</Badge>
|
| 1213 |
+
) : null}
|
| 1214 |
+
{sessionState.modes?.plan !== undefined ? <Badge tone="violet">plan mode</Badge> : null}
|
| 1215 |
+
{sessionState.modes?.swarm !== undefined ? <Badge tone="violet">swarm</Badge> : null}
|
| 1216 |
+
{sessionState.goal !== undefined ? (
|
| 1217 |
+
<Badge tone={sessionState.goal.status === 'active' ? 'sky' : 'neutral'}>
|
| 1218 |
+
goal: {sessionState.goal.status}
|
| 1219 |
+
</Badge>
|
| 1220 |
+
) : null}
|
| 1221 |
+
{sessionState.context_tokens !== undefined ? (
|
| 1222 |
+
<span className="text-[10px] text-neutral-600">
|
| 1223 |
+
ctx {sessionState.context_tokens}
|
| 1224 |
+
{sessionState.max_context_tokens !== undefined
|
| 1225 |
+
? `/${sessionState.max_context_tokens}`
|
| 1226 |
+
: ''}
|
| 1227 |
+
</span>
|
| 1228 |
+
) : null}
|
| 1229 |
+
</>
|
| 1230 |
+
);
|
| 1231 |
+
}
|
apps/kimi-inspect/src/components/DiInspectionView.tsx
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* DI view — the engine's "Service × Effect × DI" debug surface (the App-scope
|
| 3 |
+
* `debug` domain, exposed like every other Service over `/api/v1/debug`):
|
| 4 |
+
*
|
| 5 |
+
* - Units: the unit tree = ledger tree (`IDebugLedgerService.tree`) — one
|
| 6 |
+
* collapsible card per scope container, its units with the five-state
|
| 7 |
+
* cascade badge and the unprovide / update / dispose triggers
|
| 8 |
+
* (`IDebugCascadeService`), its ledger entries as a nested subtree;
|
| 9 |
+
* - Deps: the persistent dependency DAG (`IDebugGraphService.graph`) as
|
| 10 |
+
* root-based Miller columns (`di/DiGraphPanel.tsx`) — column 0 lists the
|
| 11 |
+
* roots (services nothing consumes), each click opens the next column
|
| 12 |
+
* with that service's direct dependencies; rows carry path-scoped
|
| 13 |
+
* relation bars (one per path ancestor with a direct edge) and a
|
| 14 |
+
* path-root background highlight;
|
| 15 |
+
* - Events: event subscriptions (`IDebugEventsService.subscriptions`) —
|
| 16 |
+
* unit-book ledger entries labeled `on:<name>` /
|
| 17 |
+
* `disposable:EventSubscription` per scope, plus per-bus listener counts
|
| 18 |
+
* as the fallback side (`di/DiEventsPanel.tsx`);
|
| 19 |
+
* - Cascade: the cross-scope cascade history rings
|
| 20 |
+
* (`IDebugCascadeService.history`), newest first;
|
| 21 |
+
* - Pending: the waiting area + sticky failures per scope
|
| 22 |
+
* (`IDebugCascadeService.pending`), with an `update` retry per failure.
|
| 23 |
+
*
|
| 24 |
+
* All five panels poll on a short interval and refresh eagerly when the
|
| 25 |
+
* global `event.di.unit_changed` WS frame fires (`useDiQueryInvalidation`
|
| 26 |
+
* invalidates the `['di']` query prefix).
|
| 27 |
+
*/
|
| 28 |
+
|
| 29 |
+
import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine';
|
| 30 |
+
import type { LedgerEntryInfo } from '@moonshot-ai/agent-core-v2/_base/lifecycle/ledger';
|
| 31 |
+
import {
|
| 32 |
+
IDebugCascadeService,
|
| 33 |
+
type DebugCascadeEntry,
|
| 34 |
+
type DebugPendingGroup,
|
| 35 |
+
} from '@moonshot-ai/agent-core-v2/debug/debugCascade';
|
| 36 |
+
import { IDebugGraphService, type DebugGraph } from '@moonshot-ai/agent-core-v2/debug/debugGraph';
|
| 37 |
+
import {
|
| 38 |
+
IDebugEventsService,
|
| 39 |
+
type DebugEventSubscriptions,
|
| 40 |
+
} from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents';
|
| 41 |
+
import {
|
| 42 |
+
IDebugLedgerService,
|
| 43 |
+
type DebugLedgerNode,
|
| 44 |
+
type DebugUnit,
|
| 45 |
+
} from '@moonshot-ai/agent-core-v2/debug/debugLedger';
|
| 46 |
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
| 47 |
+
import { useState, type ReactNode } from 'react';
|
| 48 |
+
|
| 49 |
+
import { useDiQueryInvalidation } from '../activity/di';
|
| 50 |
+
import type { InspectClient } from '../channel';
|
| 51 |
+
import { useConnection } from '../connection';
|
| 52 |
+
import { ActionButton, Badge, ErrorLine } from '../ui';
|
| 53 |
+
import { DiEventsPanel } from './di/DiEventsPanel';
|
| 54 |
+
import { DiGraphPanel } from './di/DiGraphPanel';
|
| 55 |
+
|
| 56 |
+
type DiPanel = 'units' | 'graph' | 'events' | 'cascade' | 'pending';
|
| 57 |
+
|
| 58 |
+
const PANELS: readonly { id: DiPanel; title: string }[] = [
|
| 59 |
+
{ id: 'units', title: 'Units' },
|
| 60 |
+
{ id: 'graph', title: 'Deps' },
|
| 61 |
+
{ id: 'events', title: 'Events' },
|
| 62 |
+
{ id: 'cascade', title: 'Cascade' },
|
| 63 |
+
{ id: 'pending', title: 'Pending' },
|
| 64 |
+
];
|
| 65 |
+
|
| 66 |
+
const REFETCH_INTERVAL_MS = 3000;
|
| 67 |
+
|
| 68 |
+
export function DiInspectionView() {
|
| 69 |
+
useDiQueryInvalidation();
|
| 70 |
+
const [panel, setPanel] = useState<DiPanel>('units');
|
| 71 |
+
return (
|
| 72 |
+
<div className="flex min-h-0 min-w-0 flex-1">
|
| 73 |
+
<nav className="w-32 shrink-0 border-r border-neutral-800 py-2">
|
| 74 |
+
<div className="px-3 pt-1 pb-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 75 |
+
DI
|
| 76 |
+
</div>
|
| 77 |
+
{PANELS.map((p) => (
|
| 78 |
+
<button
|
| 79 |
+
key={p.id}
|
| 80 |
+
type="button"
|
| 81 |
+
onClick={() => {
|
| 82 |
+
setPanel(p.id);
|
| 83 |
+
}}
|
| 84 |
+
className={`block w-full px-3 py-1 text-left text-[11px] transition-colors ${
|
| 85 |
+
panel === p.id
|
| 86 |
+
? 'bg-neutral-800 text-sky-400'
|
| 87 |
+
: 'text-neutral-500 hover:bg-neutral-800/60 hover:text-neutral-300'
|
| 88 |
+
}`}
|
| 89 |
+
>
|
| 90 |
+
{p.title}
|
| 91 |
+
</button>
|
| 92 |
+
))}
|
| 93 |
+
</nav>
|
| 94 |
+
<div className="min-w-0 flex-1 overflow-auto px-4 py-3">
|
| 95 |
+
{panel === 'units' ? (
|
| 96 |
+
<UnitsPanel />
|
| 97 |
+
) : panel === 'graph' ? (
|
| 98 |
+
<GraphPanel />
|
| 99 |
+
) : panel === 'events' ? (
|
| 100 |
+
<EventsPanel />
|
| 101 |
+
) : panel === 'cascade' ? (
|
| 102 |
+
<CascadePanel />
|
| 103 |
+
) : (
|
| 104 |
+
<PendingPanel />
|
| 105 |
+
)}
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// ---------------------------------------------------------------------------
|
| 112 |
+
// Shared data plumbing
|
| 113 |
+
// ---------------------------------------------------------------------------
|
| 114 |
+
|
| 115 |
+
function useDiQuery<T>(panel: string, fetcher: (klient: InspectClient) => Promise<T>) {
|
| 116 |
+
const { klient } = useConnection();
|
| 117 |
+
return useQuery({
|
| 118 |
+
queryKey: ['di', klient.baseUrl, panel],
|
| 119 |
+
queryFn: () => fetcher(klient),
|
| 120 |
+
refetchInterval: REFETCH_INTERVAL_MS,
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/** The loading / error gate shared by every panel. Returns null when data is ready. */
|
| 125 |
+
function panelGate(query: { isError: boolean; error: unknown; data: unknown }): ReactNode | null {
|
| 126 |
+
if (query.isError) return <ErrorLine error={query.error} />;
|
| 127 |
+
if (query.data === undefined) {
|
| 128 |
+
return <div className="text-[11px] text-neutral-600 italic">loading…</div>;
|
| 129 |
+
}
|
| 130 |
+
return null;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
type DiTriggerAction = 'unprovide' | 'update' | 'dispose';
|
| 134 |
+
|
| 135 |
+
interface DiTrigger {
|
| 136 |
+
/** `${action}:${scopePath}:${token}` of the in-flight trigger, if any. */
|
| 137 |
+
readonly busy: string | null;
|
| 138 |
+
readonly error: unknown;
|
| 139 |
+
readonly run: (action: DiTriggerAction, scopePath: string, token: string) => Promise<void>;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function triggerKey(action: DiTriggerAction, scopePath: string, token: string): string {
|
| 143 |
+
return `${action}:${scopePath}:${token}`;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* The destructive unit triggers (`IDebugCascadeService.unprovide` / `update`
|
| 148 |
+
* / `dispose`), confirm-gated like the ServiceCard danger actions; a settled
|
| 149 |
+
* trigger invalidates the `['di']` prefix so every panel converges.
|
| 150 |
+
*/
|
| 151 |
+
function useDiTrigger(): DiTrigger {
|
| 152 |
+
const { klient } = useConnection();
|
| 153 |
+
const queryClient = useQueryClient();
|
| 154 |
+
const [busy, setBusy] = useState<string | null>(null);
|
| 155 |
+
const [error, setError] = useState<unknown>(null);
|
| 156 |
+
|
| 157 |
+
const run = async (action: DiTriggerAction, scopePath: string, token: string): Promise<void> => {
|
| 158 |
+
if (!window.confirm(`${action} '${token}' @ ${scopePath}?`)) return;
|
| 159 |
+
setBusy(triggerKey(action, scopePath, token));
|
| 160 |
+
setError(null);
|
| 161 |
+
try {
|
| 162 |
+
const svc = klient.core(IDebugCascadeService);
|
| 163 |
+
if (action === 'unprovide') await svc.unprovide(scopePath, token);
|
| 164 |
+
else if (action === 'update') await svc.update(scopePath, token);
|
| 165 |
+
else await svc.dispose(scopePath, token);
|
| 166 |
+
await queryClient.invalidateQueries({ queryKey: ['di'] });
|
| 167 |
+
} catch (error) {
|
| 168 |
+
setError(error);
|
| 169 |
+
} finally {
|
| 170 |
+
setBusy(null);
|
| 171 |
+
}
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
return { busy, error, run };
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function truncate(text: string, max: number): string {
|
| 178 |
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// ---------------------------------------------------------------------------
|
| 182 |
+
// Units panel — the unit tree = ledger tree
|
| 183 |
+
// ---------------------------------------------------------------------------
|
| 184 |
+
|
| 185 |
+
const STATE_TONES: Record<UnitState, 'neutral' | 'sky' | 'green' | 'amber' | 'red'> = {
|
| 186 |
+
Pending: 'neutral',
|
| 187 |
+
Activating: 'sky',
|
| 188 |
+
Active: 'green',
|
| 189 |
+
Unloading: 'amber',
|
| 190 |
+
Failed: 'red',
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
function UnitStateBadge({ state }: { state?: UnitState }) {
|
| 194 |
+
if (state === undefined) return <Badge tone="neutral">unseen</Badge>;
|
| 195 |
+
return <Badge tone={STATE_TONES[state]}>{state}</Badge>;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
function UnitsPanel() {
|
| 199 |
+
const query = useDiQuery('units', (klient) => klient.core(IDebugLedgerService).tree());
|
| 200 |
+
const trigger = useDiTrigger();
|
| 201 |
+
const gate = panelGate(query);
|
| 202 |
+
if (gate !== null) return gate;
|
| 203 |
+
const tree = query.data as DebugLedgerNode;
|
| 204 |
+
return (
|
| 205 |
+
<div>
|
| 206 |
+
{trigger.error !== null ? (
|
| 207 |
+
<div className="mb-2">
|
| 208 |
+
<ErrorLine error={trigger.error} />
|
| 209 |
+
</div>
|
| 210 |
+
) : null}
|
| 211 |
+
<ScopeNodeCard node={tree} depth={0} trigger={trigger} />
|
| 212 |
+
</div>
|
| 213 |
+
);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function ScopeNodeCard({
|
| 217 |
+
node,
|
| 218 |
+
depth,
|
| 219 |
+
trigger,
|
| 220 |
+
}: {
|
| 221 |
+
node: DebugLedgerNode;
|
| 222 |
+
depth: number;
|
| 223 |
+
trigger: DiTrigger;
|
| 224 |
+
}) {
|
| 225 |
+
const [open, setOpen] = useState(depth < 2);
|
| 226 |
+
const counts = new Map<UnitState, number>();
|
| 227 |
+
for (const unit of node.units) {
|
| 228 |
+
if (unit.state === undefined) continue;
|
| 229 |
+
counts.set(unit.state, (counts.get(unit.state) ?? 0) + 1);
|
| 230 |
+
}
|
| 231 |
+
return (
|
| 232 |
+
<div className={depth === 0 ? '' : 'ml-3 border-l border-neutral-800/60 pl-2'}>
|
| 233 |
+
<div className="mb-2 rounded-lg border border-neutral-800 bg-neutral-900/60">
|
| 234 |
+
<div
|
| 235 |
+
className="flex cursor-pointer items-center gap-2 px-3 py-2 select-none"
|
| 236 |
+
onClick={() => {
|
| 237 |
+
setOpen((v) => !v);
|
| 238 |
+
}}
|
| 239 |
+
>
|
| 240 |
+
<span className="shrink-0 text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
|
| 241 |
+
<span className="shrink-0 text-[12px] font-medium text-neutral-200">{node.label}</span>
|
| 242 |
+
<span
|
| 243 |
+
className="min-w-0 truncate font-mono text-[10px] text-neutral-600"
|
| 244 |
+
title={node.path}
|
| 245 |
+
>
|
| 246 |
+
{node.path}
|
| 247 |
+
</span>
|
| 248 |
+
<span className="ml-auto flex shrink-0 items-center gap-1">
|
| 249 |
+
<span className="text-[10px] text-neutral-600">{node.units.length} units</span>
|
| 250 |
+
{[...counts.entries()].map(([state, count]) => (
|
| 251 |
+
<Badge key={state} tone={STATE_TONES[state]}>
|
| 252 |
+
{count} {state}
|
| 253 |
+
</Badge>
|
| 254 |
+
))}
|
| 255 |
+
</span>
|
| 256 |
+
</div>
|
| 257 |
+
{open ? (
|
| 258 |
+
<div className="border-t border-neutral-800/60 px-3 py-2">
|
| 259 |
+
{node.units.length === 0 ? (
|
| 260 |
+
<div className="text-[11px] text-neutral-600 italic">no units</div>
|
| 261 |
+
) : (
|
| 262 |
+
node.units.map((unit) => (
|
| 263 |
+
<UnitRow key={unit.token} scopePath={node.path} unit={unit} trigger={trigger} />
|
| 264 |
+
))
|
| 265 |
+
)}
|
| 266 |
+
<LedgerSection entries={node.ledger} />
|
| 267 |
+
</div>
|
| 268 |
+
) : null}
|
| 269 |
+
</div>
|
| 270 |
+
{open
|
| 271 |
+
? node.children.map((child) => (
|
| 272 |
+
<ScopeNodeCard key={child.path} node={child} depth={depth + 1} trigger={trigger} />
|
| 273 |
+
))
|
| 274 |
+
: null}
|
| 275 |
+
</div>
|
| 276 |
+
);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function UnitRow({
|
| 280 |
+
scopePath,
|
| 281 |
+
unit,
|
| 282 |
+
trigger,
|
| 283 |
+
}: {
|
| 284 |
+
scopePath: string;
|
| 285 |
+
unit: DebugUnit;
|
| 286 |
+
trigger: DiTrigger;
|
| 287 |
+
}) {
|
| 288 |
+
const [errorOpen, setErrorOpen] = useState(false);
|
| 289 |
+
const actions: readonly DiTriggerAction[] = ['unprovide', 'update', 'dispose'];
|
| 290 |
+
return (
|
| 291 |
+
<div className="mb-1 rounded border border-neutral-800/70 bg-neutral-950/40 px-2 py-1.5">
|
| 292 |
+
<div className="flex items-center gap-2">
|
| 293 |
+
<span
|
| 294 |
+
className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
|
| 295 |
+
title={unit.token}
|
| 296 |
+
>
|
| 297 |
+
{unit.token}
|
| 298 |
+
</span>
|
| 299 |
+
<span title={unit.everActive === undefined ? '' : `everActive: ${unit.everActive}`}>
|
| 300 |
+
<UnitStateBadge state={unit.state} />
|
| 301 |
+
</span>
|
| 302 |
+
{unit.inFlight === true ? <Badge tone="sky">in-flight</Badge> : null}
|
| 303 |
+
<span className="shrink-0 text-[10px] text-neutral-600">#{unit.uid}</span>
|
| 304 |
+
{unit.error !== undefined ? (
|
| 305 |
+
<button
|
| 306 |
+
type="button"
|
| 307 |
+
className="shrink-0 text-[10px] text-red-400 hover:text-red-300"
|
| 308 |
+
onClick={() => {
|
| 309 |
+
setErrorOpen((v) => !v);
|
| 310 |
+
}}
|
| 311 |
+
>
|
| 312 |
+
error {errorOpen ? '▾' : '▸'}
|
| 313 |
+
</button>
|
| 314 |
+
) : null}
|
| 315 |
+
<span className="ml-auto flex shrink-0 gap-1">
|
| 316 |
+
{actions.map((action) => (
|
| 317 |
+
<ActionButton
|
| 318 |
+
key={action}
|
| 319 |
+
danger
|
| 320 |
+
disabled={trigger.busy !== null}
|
| 321 |
+
onClick={() => void trigger.run(action, scopePath, unit.token)}
|
| 322 |
+
>
|
| 323 |
+
{trigger.busy === triggerKey(action, scopePath, unit.token) ? '…' : action}
|
| 324 |
+
</ActionButton>
|
| 325 |
+
))}
|
| 326 |
+
</span>
|
| 327 |
+
</div>
|
| 328 |
+
{errorOpen && unit.error !== undefined ? (
|
| 329 |
+
<pre className="mt-1 overflow-auto rounded bg-red-950/40 p-1.5 font-mono text-[10px] break-words whitespace-pre-wrap text-red-300">
|
| 330 |
+
{unit.error}
|
| 331 |
+
</pre>
|
| 332 |
+
) : null}
|
| 333 |
+
</div>
|
| 334 |
+
);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
const LEDGER_KIND_TONES: Record<LedgerEntryInfo['kind'], 'neutral' | 'sky' | 'violet'> = {
|
| 338 |
+
disposer: 'neutral',
|
| 339 |
+
effect: 'sky',
|
| 340 |
+
ledger: 'violet',
|
| 341 |
+
};
|
| 342 |
+
|
| 343 |
+
function LedgerSection({ entries }: { entries: readonly LedgerEntryInfo[] }) {
|
| 344 |
+
const [open, setOpen] = useState(false);
|
| 345 |
+
if (entries.length === 0) return null;
|
| 346 |
+
return (
|
| 347 |
+
<div className="mt-1">
|
| 348 |
+
<button
|
| 349 |
+
type="button"
|
| 350 |
+
className="text-[10px] text-neutral-500 select-none hover:text-neutral-300"
|
| 351 |
+
onClick={() => {
|
| 352 |
+
setOpen((v) => !v);
|
| 353 |
+
}}
|
| 354 |
+
>
|
| 355 |
+
{open ? '▾' : '▸'} ledger ({entries.length})
|
| 356 |
+
</button>
|
| 357 |
+
{open ? (
|
| 358 |
+
<div className="mt-1 border-l border-neutral-800/60 pl-2">
|
| 359 |
+
{entries.map((entry, i) => (
|
| 360 |
+
<LedgerEntryRow key={i} entry={entry} />
|
| 361 |
+
))}
|
| 362 |
+
</div>
|
| 363 |
+
) : null}
|
| 364 |
+
</div>
|
| 365 |
+
);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
function LedgerEntryRow({ entry }: { entry: LedgerEntryInfo }) {
|
| 369 |
+
return (
|
| 370 |
+
<div className="py-0.5">
|
| 371 |
+
<span className="font-mono text-[10px] text-neutral-400" title={entry.stack}>
|
| 372 |
+
{entry.label}
|
| 373 |
+
</span>
|
| 374 |
+
<span className="ml-1.5">
|
| 375 |
+
<Badge tone={LEDGER_KIND_TONES[entry.kind]}>{entry.kind}</Badge>
|
| 376 |
+
</span>
|
| 377 |
+
{entry.children !== undefined && entry.children.length > 0 ? (
|
| 378 |
+
<div className="ml-2 border-l border-neutral-800/60 pl-2">
|
| 379 |
+
{entry.children.map((child, i) => (
|
| 380 |
+
<LedgerEntryRow key={i} entry={child} />
|
| 381 |
+
))}
|
| 382 |
+
</div>
|
| 383 |
+
) : null}
|
| 384 |
+
</div>
|
| 385 |
+
);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// ---------------------------------------------------------------------------
|
| 389 |
+
// Graph panel — the dependency DAG; rendering lives in di/DiGraphPanel.tsx
|
| 390 |
+
// ---------------------------------------------------------------------------
|
| 391 |
+
|
| 392 |
+
function GraphPanel() {
|
| 393 |
+
const query = useDiQuery('graph', (klient) => klient.core(IDebugGraphService).graph());
|
| 394 |
+
const gate = panelGate(query);
|
| 395 |
+
if (gate !== null) return gate;
|
| 396 |
+
return <DiGraphPanel graph={query.data as DebugGraph} />;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
// ---------------------------------------------------------------------------
|
| 400 |
+
// Events panel — event subscriptions; rendering lives in di/DiEventsPanel.tsx
|
| 401 |
+
// ---------------------------------------------------------------------------
|
| 402 |
+
|
| 403 |
+
function EventsPanel() {
|
| 404 |
+
const query = useDiQuery('events', (klient) =>
|
| 405 |
+
klient.core(IDebugEventsService).subscriptions(),
|
| 406 |
+
);
|
| 407 |
+
const gate = panelGate(query);
|
| 408 |
+
if (gate !== null) return gate;
|
| 409 |
+
return <DiEventsPanel data={query.data as DebugEventSubscriptions} />;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
// ---------------------------------------------------------------------------
|
| 413 |
+
// Cascade panel — the cross-scope cascade history rings, newest first
|
| 414 |
+
// ---------------------------------------------------------------------------
|
| 415 |
+
|
| 416 |
+
function CascadePanel() {
|
| 417 |
+
const query = useDiQuery('cascade', (klient) => klient.core(IDebugCascadeService).history());
|
| 418 |
+
const gate = panelGate(query);
|
| 419 |
+
if (gate !== null) return gate;
|
| 420 |
+
// seq is a per-engine ring sequence; cross-scope ordering is approximate.
|
| 421 |
+
const entries = (query.data as DebugCascadeEntry[]).toReversed();
|
| 422 |
+
if (entries.length === 0) {
|
| 423 |
+
return <div className="text-[11px] text-neutral-600 italic">no cascade transactions yet</div>;
|
| 424 |
+
}
|
| 425 |
+
return (
|
| 426 |
+
<div>
|
| 427 |
+
{entries.map((entry, i) => (
|
| 428 |
+
<CascadeEntryCard key={`${entry.scopePath}:${entry.seq}:${i}`} entry={entry} />
|
| 429 |
+
))}
|
| 430 |
+
</div>
|
| 431 |
+
);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
function CascadeEntryCard({ entry }: { entry: DebugCascadeEntry }) {
|
| 435 |
+
const [open, setOpen] = useState(false);
|
| 436 |
+
const expandable =
|
| 437 |
+
entry.failed.length > 0 || entry.affected.length > 0 || entry.tornDown.length > 0;
|
| 438 |
+
return (
|
| 439 |
+
<div className="mb-2 rounded border border-neutral-800 bg-neutral-900/60">
|
| 440 |
+
<div
|
| 441 |
+
className={`flex items-center gap-2 px-2 py-1.5 ${expandable ? 'cursor-pointer select-none' : ''}`}
|
| 442 |
+
onClick={
|
| 443 |
+
expandable
|
| 444 |
+
? () => {
|
| 445 |
+
setOpen((v) => !v);
|
| 446 |
+
}
|
| 447 |
+
: undefined
|
| 448 |
+
}
|
| 449 |
+
>
|
| 450 |
+
<span className="shrink-0 font-mono text-[11px] text-neutral-200">#{entry.seq}</span>
|
| 451 |
+
<span className="shrink-0 font-mono text-[10px] text-neutral-500" title={entry.scopePath}>
|
| 452 |
+
{truncate(entry.scopePath, 40)}
|
| 453 |
+
</span>
|
| 454 |
+
{entry.abortWaited ? <Badge tone="amber">abort waited</Badge> : null}
|
| 455 |
+
{entry.abortTimedOut ? <Badge tone="red">abort timed out</Badge> : null}
|
| 456 |
+
{entry.failed.length > 0 ? <Badge tone="red">{entry.failed.length} failed</Badge> : null}
|
| 457 |
+
<span className="min-w-0 flex-1 truncate text-[11px] text-neutral-400" title={entry.reason}>
|
| 458 |
+
{entry.reason}
|
| 459 |
+
</span>
|
| 460 |
+
<span className="shrink-0 text-[10px] text-neutral-600">{entry.durationMs}ms</span>
|
| 461 |
+
{expandable ? (
|
| 462 |
+
<span className="shrink-0 text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
|
| 463 |
+
) : null}
|
| 464 |
+
</div>
|
| 465 |
+
<div className="border-t border-neutral-800/60 px-2 py-1.5">
|
| 466 |
+
<div className="flex flex-wrap gap-1">
|
| 467 |
+
{entry.changes.map((change, i) => (
|
| 468 |
+
<span
|
| 469 |
+
key={i}
|
| 470 |
+
className="rounded bg-neutral-800 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300"
|
| 471 |
+
title={change.token}
|
| 472 |
+
>
|
| 473 |
+
{change.action} {truncate(change.token, 48)}
|
| 474 |
+
</span>
|
| 475 |
+
))}
|
| 476 |
+
</div>
|
| 477 |
+
<div className="mt-1 text-[10px] text-neutral-500">
|
| 478 |
+
affected {entry.affected.length} · torn down {entry.tornDown.length} · rebuilt{' '}
|
| 479 |
+
{entry.rebuilt.length} · failed {entry.failed.length}
|
| 480 |
+
</div>
|
| 481 |
+
{open ? (
|
| 482 |
+
<div className="mt-1.5 space-y-1">
|
| 483 |
+
{entry.affected.length > 0 ? (
|
| 484 |
+
<TokenList label="affected" tokens={entry.affected} />
|
| 485 |
+
) : null}
|
| 486 |
+
{entry.tornDown.length > 0 ? (
|
| 487 |
+
<TokenList label="torn down" tokens={entry.tornDown} />
|
| 488 |
+
) : null}
|
| 489 |
+
{entry.failed.length > 0 ? (
|
| 490 |
+
<TokenList label="failed" tokens={entry.failed} danger />
|
| 491 |
+
) : null}
|
| 492 |
+
</div>
|
| 493 |
+
) : null}
|
| 494 |
+
</div>
|
| 495 |
+
</div>
|
| 496 |
+
);
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
function TokenList({
|
| 500 |
+
label,
|
| 501 |
+
tokens,
|
| 502 |
+
danger,
|
| 503 |
+
}: {
|
| 504 |
+
label: string;
|
| 505 |
+
tokens: readonly string[];
|
| 506 |
+
danger?: boolean;
|
| 507 |
+
}) {
|
| 508 |
+
return (
|
| 509 |
+
<div>
|
| 510 |
+
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
|
| 511 |
+
{label}
|
| 512 |
+
</div>
|
| 513 |
+
<div className="flex flex-wrap gap-1">
|
| 514 |
+
{tokens.map((token, i) => (
|
| 515 |
+
<span
|
| 516 |
+
key={i}
|
| 517 |
+
className={`rounded px-1.5 py-0.5 font-mono text-[10px] ${
|
| 518 |
+
danger === true ? 'bg-red-950/60 text-red-300' : 'bg-neutral-800 text-neutral-300'
|
| 519 |
+
}`}
|
| 520 |
+
title={token}
|
| 521 |
+
>
|
| 522 |
+
{truncate(token, 64)}
|
| 523 |
+
</span>
|
| 524 |
+
))}
|
| 525 |
+
</div>
|
| 526 |
+
</div>
|
| 527 |
+
);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
// ---------------------------------------------------------------------------
|
| 531 |
+
// Pending panel — the waiting area + sticky failures per scope
|
| 532 |
+
// ---------------------------------------------------------------------------
|
| 533 |
+
|
| 534 |
+
function PendingPanel() {
|
| 535 |
+
const query = useDiQuery('pending', (klient) => klient.core(IDebugCascadeService).pending());
|
| 536 |
+
const trigger = useDiTrigger();
|
| 537 |
+
const gate = panelGate(query);
|
| 538 |
+
if (gate !== null) return gate;
|
| 539 |
+
const groups = query.data as DebugPendingGroup[];
|
| 540 |
+
return (
|
| 541 |
+
<div>
|
| 542 |
+
{trigger.error !== null ? (
|
| 543 |
+
<div className="mb-2">
|
| 544 |
+
<ErrorLine error={trigger.error} />
|
| 545 |
+
</div>
|
| 546 |
+
) : null}
|
| 547 |
+
{groups.length === 0 ? (
|
| 548 |
+
<div className="text-[11px] text-neutral-600 italic">no waiting or failed units</div>
|
| 549 |
+
) : (
|
| 550 |
+
groups.map((group) => (
|
| 551 |
+
<div
|
| 552 |
+
key={group.scopePath}
|
| 553 |
+
className="mb-2 rounded-lg border border-neutral-800 bg-neutral-900/60"
|
| 554 |
+
>
|
| 555 |
+
<div className="border-b border-neutral-800/60 px-3 py-2">
|
| 556 |
+
<span className="font-mono text-[11px] text-neutral-200">{group.scopePath}</span>
|
| 557 |
+
</div>
|
| 558 |
+
<div className="px-3 py-2">
|
| 559 |
+
{group.waiting.length > 0 ? (
|
| 560 |
+
<div className="mb-2">
|
| 561 |
+
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
|
| 562 |
+
waiting ({group.waiting.length})
|
| 563 |
+
</div>
|
| 564 |
+
{group.waiting.map((unit) => (
|
| 565 |
+
<div
|
| 566 |
+
key={unit.token}
|
| 567 |
+
className="mb-1 flex items-center gap-2 rounded border border-neutral-800/70 bg-neutral-950/40 px-2 py-1.5"
|
| 568 |
+
>
|
| 569 |
+
<span
|
| 570 |
+
className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
|
| 571 |
+
title={unit.token}
|
| 572 |
+
>
|
| 573 |
+
{unit.token}
|
| 574 |
+
</span>
|
| 575 |
+
<span className="shrink-0 text-[10px] text-neutral-600">missing:</span>
|
| 576 |
+
<span className="flex min-w-0 flex-wrap gap-1">
|
| 577 |
+
{unit.missing.map((dep, i) => (
|
| 578 |
+
<span
|
| 579 |
+
key={i}
|
| 580 |
+
className="rounded bg-amber-950/60 px-1.5 py-0.5 font-mono text-[10px] text-amber-300"
|
| 581 |
+
title={dep}
|
| 582 |
+
>
|
| 583 |
+
{truncate(dep, 48)}
|
| 584 |
+
</span>
|
| 585 |
+
))}
|
| 586 |
+
</span>
|
| 587 |
+
</div>
|
| 588 |
+
))}
|
| 589 |
+
</div>
|
| 590 |
+
) : null}
|
| 591 |
+
{group.failed.length > 0 ? (
|
| 592 |
+
<div>
|
| 593 |
+
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
|
| 594 |
+
failed ({group.failed.length})
|
| 595 |
+
</div>
|
| 596 |
+
{group.failed.map((unit) => (
|
| 597 |
+
<div
|
| 598 |
+
key={unit.token}
|
| 599 |
+
className="mb-1 rounded border border-red-900/50 bg-red-950/20 px-2 py-1.5"
|
| 600 |
+
>
|
| 601 |
+
<div className="flex items-center gap-2">
|
| 602 |
+
<span
|
| 603 |
+
className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
|
| 604 |
+
title={unit.token}
|
| 605 |
+
>
|
| 606 |
+
{unit.token}
|
| 607 |
+
</span>
|
| 608 |
+
<span className="ml-auto shrink-0">
|
| 609 |
+
<ActionButton
|
| 610 |
+
danger
|
| 611 |
+
disabled={trigger.busy !== null}
|
| 612 |
+
onClick={() => void trigger.run('update', group.scopePath, unit.token)}
|
| 613 |
+
>
|
| 614 |
+
{trigger.busy === triggerKey('update', group.scopePath, unit.token)
|
| 615 |
+
? '…'
|
| 616 |
+
: 'retry update'}
|
| 617 |
+
</ActionButton>
|
| 618 |
+
</span>
|
| 619 |
+
</div>
|
| 620 |
+
{unit.error !== undefined ? (
|
| 621 |
+
<pre className="mt-1 overflow-auto rounded bg-red-950/40 p-1.5 font-mono text-[10px] break-words whitespace-pre-wrap text-red-300">
|
| 622 |
+
{unit.error}
|
| 623 |
+
</pre>
|
| 624 |
+
) : null}
|
| 625 |
+
</div>
|
| 626 |
+
))}
|
| 627 |
+
</div>
|
| 628 |
+
) : null}
|
| 629 |
+
</div>
|
| 630 |
+
</div>
|
| 631 |
+
))
|
| 632 |
+
)}
|
| 633 |
+
</div>
|
| 634 |
+
);
|
| 635 |
+
}
|
apps/kimi-inspect/src/components/FsSuggestView.tsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IWorkspaceService, type Workspace } from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
|
| 2 |
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
|
| 5 |
+
import { useConnection } from '../connection';
|
| 6 |
+
import { fetchFsSuggest, type FsSuggestResult } from '../fs/api';
|
| 7 |
+
import { Badge, ErrorLine } from '../ui';
|
| 8 |
+
import { WorkspaceDirBrowser } from './WorkspaceDirBrowser';
|
| 9 |
+
|
| 10 |
+
function parseGlobs(value: string): string[] | undefined {
|
| 11 |
+
const globs = value
|
| 12 |
+
.split(',')
|
| 13 |
+
.map((glob) => glob.trim())
|
| 14 |
+
.filter((glob) => glob.length > 0);
|
| 15 |
+
return globs.length === 0 ? undefined : globs;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function parseRoots(value: string): string[] {
|
| 19 |
+
return value
|
| 20 |
+
.split(/[\n,]/)
|
| 21 |
+
.map((root) => root.trim())
|
| 22 |
+
.filter((root) => root.length > 0);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function FsSuggestView() {
|
| 26 |
+
const { klient, baseUrl } = useConnection();
|
| 27 |
+
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
| 28 |
+
const [rootsText, setRootsText] = useState('');
|
| 29 |
+
const [query, setQuery] = useState('');
|
| 30 |
+
const [limit, setLimit] = useState('50');
|
| 31 |
+
const [followGitignore, setFollowGitignore] = useState(true);
|
| 32 |
+
const [showHidden, setShowHidden] = useState(false);
|
| 33 |
+
const [includeGlobs, setIncludeGlobs] = useState('');
|
| 34 |
+
const [excludeGlobs, setExcludeGlobs] = useState('');
|
| 35 |
+
|
| 36 |
+
const workspaces = useQuery({
|
| 37 |
+
queryKey: ['workspaces', klient.baseUrl],
|
| 38 |
+
queryFn: () => klient.core(IWorkspaceService).list(),
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
const suggest = useMutation<FsSuggestResult, Error>({
|
| 42 |
+
mutationFn: async () => {
|
| 43 |
+
const roots = parseRoots(rootsText);
|
| 44 |
+
const parsedLimit = Number.parseInt(limit, 10);
|
| 45 |
+
const shared = {
|
| 46 |
+
baseUrl: klient.baseUrl,
|
| 47 |
+
token: klient.token,
|
| 48 |
+
query,
|
| 49 |
+
limit: Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : undefined,
|
| 50 |
+
followGitignore,
|
| 51 |
+
showHidden,
|
| 52 |
+
includeGlobs: parseGlobs(includeGlobs),
|
| 53 |
+
excludeGlobs: parseGlobs(excludeGlobs),
|
| 54 |
+
};
|
| 55 |
+
if (roots.length > 0) {
|
| 56 |
+
return fetchFsSuggest({ ...shared, roots });
|
| 57 |
+
}
|
| 58 |
+
if (workspace === null) throw new Error('select a workspace or enter roots first');
|
| 59 |
+
return fetchFsSuggest({ ...shared, roots: [workspace.root] });
|
| 60 |
+
},
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
setWorkspace(null);
|
| 65 |
+
suggest.reset();
|
| 66 |
+
}, [baseUrl]);
|
| 67 |
+
|
| 68 |
+
const selectWorkspace = (next: Workspace) => {
|
| 69 |
+
setWorkspace(next);
|
| 70 |
+
suggest.reset();
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div className="flex min-h-0 min-w-0 flex-1">
|
| 75 |
+
<aside className="flex w-72 shrink-0 flex-col border-r border-neutral-800">
|
| 76 |
+
<div className="border-b border-neutral-800 px-3 py-2">
|
| 77 |
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 78 |
+
Workspace
|
| 79 |
+
</div>
|
| 80 |
+
<div title={workspace?.root} className="truncate font-mono text-[11px] text-neutral-300">
|
| 81 |
+
{workspace === null ? <span className="text-neutral-600 italic">none selected</span> : `${workspace.name} — ${workspace.root}`}
|
| 82 |
+
</div>
|
| 83 |
+
{workspaces.isError ? <ErrorLine error={workspaces.error} /> : null}
|
| 84 |
+
</div>
|
| 85 |
+
<WorkspaceDirBrowser
|
| 86 |
+
klient={klient}
|
| 87 |
+
workspaces={workspaces.data}
|
| 88 |
+
onSelect={selectWorkspace}
|
| 89 |
+
/>
|
| 90 |
+
</aside>
|
| 91 |
+
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4">
|
| 92 |
+
<div className="mx-auto max-w-6xl space-y-4">
|
| 93 |
+
<div>
|
| 94 |
+
<h1 className="text-sm font-semibold text-neutral-200">Filesystem Suggest</h1>
|
| 95 |
+
<p className="mt-1 text-[11px] text-neutral-500">
|
| 96 |
+
Query file and directory completion candidates via the workspace-independent
|
| 97 |
+
fs:suggest API — the selected workspace supplies its root, or enter arbitrary
|
| 98 |
+
roots to override it.
|
| 99 |
+
</p>
|
| 100 |
+
</div>
|
| 101 |
+
<form
|
| 102 |
+
className="grid gap-3 rounded border border-neutral-800 bg-neutral-900/30 p-3 md:grid-cols-2"
|
| 103 |
+
onSubmit={(event) => {
|
| 104 |
+
event.preventDefault();
|
| 105 |
+
suggest.mutate();
|
| 106 |
+
}}
|
| 107 |
+
>
|
| 108 |
+
<label className="md:col-span-2">
|
| 109 |
+
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 110 |
+
Roots (absolute paths, one per line or comma-separated; overrides the workspace selection)
|
| 111 |
+
</span>
|
| 112 |
+
<textarea
|
| 113 |
+
className="h-16 w-full resize-y rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[12px] text-neutral-100 outline-none focus:border-sky-600"
|
| 114 |
+
value={rootsText}
|
| 115 |
+
onChange={(event) => setRootsText(event.target.value)}
|
| 116 |
+
placeholder="/abs/primary-root /abs/additional-root"
|
| 117 |
+
/>
|
| 118 |
+
</label>
|
| 119 |
+
<label className="md:col-span-2">
|
| 120 |
+
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 121 |
+
Query
|
| 122 |
+
</span>
|
| 123 |
+
<input
|
| 124 |
+
autoFocus
|
| 125 |
+
className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[12px] text-neutral-100 outline-none focus:border-sky-600"
|
| 126 |
+
value={query}
|
| 127 |
+
onChange={(event) => setQuery(event.target.value)}
|
| 128 |
+
placeholder="apps/de or README"
|
| 129 |
+
/>
|
| 130 |
+
</label>
|
| 131 |
+
<label>
|
| 132 |
+
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 133 |
+
Limit
|
| 134 |
+
</span>
|
| 135 |
+
<input
|
| 136 |
+
className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[12px] text-neutral-100 outline-none focus:border-sky-600"
|
| 137 |
+
inputMode="numeric"
|
| 138 |
+
value={limit}
|
| 139 |
+
onChange={(event) => setLimit(event.target.value)}
|
| 140 |
+
/>
|
| 141 |
+
</label>
|
| 142 |
+
<div className="flex items-end gap-4 pb-1 text-[11px] text-neutral-300">
|
| 143 |
+
<label className="flex items-center gap-1.5">
|
| 144 |
+
<input
|
| 145 |
+
type="checkbox"
|
| 146 |
+
checked={followGitignore}
|
| 147 |
+
onChange={(event) => setFollowGitignore(event.target.checked)}
|
| 148 |
+
/>
|
| 149 |
+
follow gitignore
|
| 150 |
+
</label>
|
| 151 |
+
<label className="flex items-center gap-1.5">
|
| 152 |
+
<input
|
| 153 |
+
type="checkbox"
|
| 154 |
+
checked={showHidden}
|
| 155 |
+
onChange={(event) => setShowHidden(event.target.checked)}
|
| 156 |
+
/>
|
| 157 |
+
show hidden
|
| 158 |
+
</label>
|
| 159 |
+
</div>
|
| 160 |
+
<label>
|
| 161 |
+
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 162 |
+
Include globs
|
| 163 |
+
</span>
|
| 164 |
+
<input
|
| 165 |
+
className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
| 166 |
+
value={includeGlobs}
|
| 167 |
+
onChange={(event) => setIncludeGlobs(event.target.value)}
|
| 168 |
+
placeholder="**/*.ts, src/**"
|
| 169 |
+
/>
|
| 170 |
+
</label>
|
| 171 |
+
<label>
|
| 172 |
+
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 173 |
+
Exclude globs
|
| 174 |
+
</span>
|
| 175 |
+
<input
|
| 176 |
+
className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
| 177 |
+
value={excludeGlobs}
|
| 178 |
+
onChange={(event) => setExcludeGlobs(event.target.value)}
|
| 179 |
+
placeholder="dist/**, node_modules/**"
|
| 180 |
+
/>
|
| 181 |
+
</label>
|
| 182 |
+
<div className="flex items-end md:col-span-2">
|
| 183 |
+
<button
|
| 184 |
+
type="submit"
|
| 185 |
+
disabled={(workspace === null && parseRoots(rootsText).length === 0) || suggest.isPending}
|
| 186 |
+
className="rounded bg-sky-600 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-sky-500 disabled:opacity-40"
|
| 187 |
+
>
|
| 188 |
+
{suggest.isPending ? 'loading…' : 'Suggest'}
|
| 189 |
+
</button>
|
| 190 |
+
{workspace === null && parseRoots(rootsText).length === 0 ? (
|
| 191 |
+
<span className="ml-3 text-[11px] text-neutral-600">select a workspace or enter roots first</span>
|
| 192 |
+
) : null}
|
| 193 |
+
</div>
|
| 194 |
+
</form>
|
| 195 |
+
{suggest.isError ? <ErrorLine error={suggest.error} /> : null}
|
| 196 |
+
{suggest.data === undefined && !suggest.isError ? (
|
| 197 |
+
<div className="rounded border border-dashed border-neutral-800 p-6 text-center text-[12px] text-neutral-600">
|
| 198 |
+
Submit a query to inspect the complete response.
|
| 199 |
+
</div>
|
| 200 |
+
) : null}
|
| 201 |
+
{suggest.data !== undefined ? <SuggestResult result={suggest.data} /> : null}
|
| 202 |
+
</div>
|
| 203 |
+
</main>
|
| 204 |
+
</div>
|
| 205 |
+
);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function SuggestResult({ result }: { readonly result: FsSuggestResult }) {
|
| 209 |
+
return (
|
| 210 |
+
<div className="space-y-3">
|
| 211 |
+
<div className="flex items-center gap-2 text-[11px] text-neutral-400">
|
| 212 |
+
<span>{result.items.length} items</span>
|
| 213 |
+
<Badge tone={result.truncated ? 'amber' : 'green'}>
|
| 214 |
+
{result.truncated ? 'truncated' : 'complete'}
|
| 215 |
+
</Badge>
|
| 216 |
+
</div>
|
| 217 |
+
<section className="overflow-x-auto rounded border border-neutral-800">
|
| 218 |
+
<table className="w-full min-w-[680px] text-left text-[11px]">
|
| 219 |
+
<thead className="border-b border-neutral-800 bg-neutral-900/50 text-neutral-500">
|
| 220 |
+
<tr>
|
| 221 |
+
<th className="px-2 py-1.5">path</th>
|
| 222 |
+
<th className="px-2 py-1.5">name</th>
|
| 223 |
+
<th className="px-2 py-1.5">kind</th>
|
| 224 |
+
<th className="px-2 py-1.5">score</th>
|
| 225 |
+
<th className="px-2 py-1.5">match positions</th>
|
| 226 |
+
</tr>
|
| 227 |
+
</thead>
|
| 228 |
+
<tbody>
|
| 229 |
+
{result.items.map((item) => (
|
| 230 |
+
<tr key={item.path} className="border-b border-neutral-900 last:border-0">
|
| 231 |
+
<td className="px-2 py-1.5 font-mono text-neutral-200">{item.path}</td>
|
| 232 |
+
<td className="px-2 py-1.5 font-mono text-neutral-400">{item.name}</td>
|
| 233 |
+
<td className="px-2 py-1.5 text-neutral-400">{item.kind}</td>
|
| 234 |
+
<td className="px-2 py-1.5 font-mono text-neutral-400">{item.score}</td>
|
| 235 |
+
<td className="px-2 py-1.5 font-mono text-neutral-400">
|
| 236 |
+
{item.matchPositions.join(', ') || '—'}
|
| 237 |
+
</td>
|
| 238 |
+
</tr>
|
| 239 |
+
))}
|
| 240 |
+
</tbody>
|
| 241 |
+
</table>
|
| 242 |
+
{result.items.length === 0 ? (
|
| 243 |
+
<div className="p-4 text-center text-[11px] text-neutral-600">no matching items</div>
|
| 244 |
+
) : null}
|
| 245 |
+
</section>
|
| 246 |
+
<details className="rounded border border-neutral-800 bg-neutral-950/50">
|
| 247 |
+
<summary className="cursor-pointer px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 248 |
+
Full JSON response
|
| 249 |
+
</summary>
|
| 250 |
+
<pre className="max-h-[420px] overflow-auto border-t border-neutral-800 p-3 text-[11px] leading-relaxed text-neutral-300">
|
| 251 |
+
{JSON.stringify(result, null, 2)}
|
| 252 |
+
</pre>
|
| 253 |
+
</details>
|
| 254 |
+
</div>
|
| 255 |
+
);
|
| 256 |
+
}
|
apps/kimi-inspect/src/components/Inspector.tsx
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Agent inspector — the `Agent` tab of the chat view's right dock
|
| 3 |
+
* (`RightPanel`; it used to be a standalone 420px column on the far right).
|
| 4 |
+
* Hosts the agent switcher, the Plan lookup card, and the agent Service
|
| 5 |
+
* panels (`ScopePanels`). The session scope lives in the `SessionPane`
|
| 6 |
+
* column next to the sidebar (pending interactions, session Services,
|
| 7 |
+
* session State); the app-scope (server-level) Services live in their own
|
| 8 |
+
* rail view (`AppServicesView`), not here.
|
| 9 |
+
*
|
| 10 |
+
* Everything here is fetch-on-demand (Load / Refresh buttons): the v2 event
|
| 11 |
+
* socket (`/api/v2/ws`) that used to push core/session/agent event streams
|
| 12 |
+
* — live panel refetches, the pending-interaction push, the merged event
|
| 13 |
+
* log — was removed server-side, so there is no live push to render.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
|
| 17 |
+
import { useQuery } from '@tanstack/react-query';
|
| 18 |
+
import { useEffect, useMemo, useState } from 'react';
|
| 19 |
+
|
| 20 |
+
import { serviceByName } from '../channel';
|
| 21 |
+
import { useConnection } from '../connection';
|
| 22 |
+
import { type AnyService } from '../panels';
|
| 23 |
+
import { fetchAgentRuntimeBinding } from '../snapshots/api';
|
| 24 |
+
import { fetchFullHistory } from '../transcript/api';
|
| 25 |
+
import { projectPlans, type PlanInfo } from '../transcript/plan';
|
| 26 |
+
import { ActionButton, Badge, ErrorLine } from '../ui';
|
| 27 |
+
import { ScopePanels } from './ServicePanels';
|
| 28 |
+
|
| 29 |
+
export function Inspector({
|
| 30 |
+
sessionId,
|
| 31 |
+
agentId,
|
| 32 |
+
onAgentChange,
|
| 33 |
+
ready,
|
| 34 |
+
}: {
|
| 35 |
+
sessionId: string | null;
|
| 36 |
+
agentId: string;
|
| 37 |
+
onAgentChange: (agentId: string) => void;
|
| 38 |
+
ready: boolean;
|
| 39 |
+
}) {
|
| 40 |
+
const { klient } = useConnection();
|
| 41 |
+
|
| 42 |
+
const meta = useQuery({
|
| 43 |
+
queryKey: ['sessionMeta', sessionId],
|
| 44 |
+
queryFn: () =>
|
| 45 |
+
klient
|
| 46 |
+
.session(sessionId as string)
|
| 47 |
+
.service(ISessionMetadata)
|
| 48 |
+
.read(),
|
| 49 |
+
enabled: sessionId !== null && ready,
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
const agentIds = useMemo(() => {
|
| 53 |
+
const ids = Object.keys(meta.data?.agents ?? {});
|
| 54 |
+
if (ids.length === 0) return ['main'];
|
| 55 |
+
return ['main', ...ids.filter((id) => id !== 'main')].filter(
|
| 56 |
+
(id, i, all) => all.indexOf(id) === i,
|
| 57 |
+
);
|
| 58 |
+
}, [meta.data]);
|
| 59 |
+
|
| 60 |
+
// Keep the selected agent valid as the registry changes.
|
| 61 |
+
const effectiveAgent = agentIds.includes(agentId) ? agentId : agentIds[0]!;
|
| 62 |
+
const runtimeBinding = useQuery({
|
| 63 |
+
queryKey: ['agent-runtime-binding', klient.baseUrl, sessionId, effectiveAgent],
|
| 64 |
+
queryFn: () => fetchAgentRuntimeBinding(klient, sessionId as string, effectiveAgent),
|
| 65 |
+
enabled: sessionId !== null && ready,
|
| 66 |
+
refetchInterval: 1_000,
|
| 67 |
+
});
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
if (effectiveAgent !== agentId) onAgentChange(effectiveAgent);
|
| 70 |
+
}, [effectiveAgent, agentId, onAgentChange]);
|
| 71 |
+
|
| 72 |
+
// Subagents stay in the metadata registry even when their scope is not
|
| 73 |
+
// materialized in this process (created before a restart, or disposed on
|
| 74 |
+
// session close), so the switcher lists entries that cannot be called.
|
| 75 |
+
// Mark one as "not loaded" when an agent-scope call comes back with
|
| 76 |
+
// `agent.not_found` (message names the agent).
|
| 77 |
+
const [stoppedAgents, setStoppedAgents] = useState<ReadonlySet<string>>(new Set());
|
| 78 |
+
useEffect(() => setStoppedAgents(new Set()), [sessionId]);
|
| 79 |
+
const noteAgentError = (agent: string, error: unknown) => {
|
| 80 |
+
if (error instanceof Error && error.message.includes('not found in session')) {
|
| 81 |
+
setStoppedAgents((prev) => (prev.has(agent) ? prev : new Set(prev).add(agent)));
|
| 82 |
+
}
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
// Resolve a Service proxy by channel name, 1:1 with the channel descriptor
|
| 86 |
+
// from `/api/v1/debug/channels`. Returns null when the scope needs a
|
| 87 |
+
// session that isn't selected/ready.
|
| 88 |
+
const proxyFor = useMemo(() => {
|
| 89 |
+
return (name: string): AnyService | null => {
|
| 90 |
+
return (
|
| 91 |
+
serviceByName<AnyService>(klient, name, {
|
| 92 |
+
scope: 'agent',
|
| 93 |
+
sessionId: sessionId !== null && ready ? sessionId : undefined,
|
| 94 |
+
agentId: effectiveAgent,
|
| 95 |
+
}) ?? null
|
| 96 |
+
);
|
| 97 |
+
};
|
| 98 |
+
}, [klient, sessionId, effectiveAgent, ready]);
|
| 99 |
+
|
| 100 |
+
const sessionBlocked = sessionId === null || !ready;
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<div className="flex h-full flex-col">
|
| 104 |
+
{/* Agent switcher */}
|
| 105 |
+
{sessionId !== null ? (
|
| 106 |
+
<div className="border-b border-neutral-800 px-3 py-2">
|
| 107 |
+
<label className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 108 |
+
Active agent
|
| 109 |
+
</label>
|
| 110 |
+
<select
|
| 111 |
+
className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-100 outline-none focus:border-sky-600"
|
| 112 |
+
value={effectiveAgent}
|
| 113 |
+
onChange={(e) => onAgentChange(e.target.value)}
|
| 114 |
+
>
|
| 115 |
+
{agentIds.map((id) => (
|
| 116 |
+
<option key={id} value={id}>
|
| 117 |
+
{stoppedAgents.has(id) ? `${id} (not loaded)` : id}
|
| 118 |
+
</option>
|
| 119 |
+
))}
|
| 120 |
+
</select>
|
| 121 |
+
{stoppedAgents.has(effectiveAgent) ? (
|
| 122 |
+
<div className="mt-1 text-[10px] text-neutral-600">
|
| 123 |
+
this agent is not materialized in the running server (e.g. created before a restart) —
|
| 124 |
+
calls will fail; its persisted records remain on disk
|
| 125 |
+
</div>
|
| 126 |
+
) : null}
|
| 127 |
+
{meta.isError ? (
|
| 128 |
+
<div className="mt-1">
|
| 129 |
+
<ErrorLine error={meta.error} />
|
| 130 |
+
</div>
|
| 131 |
+
) : null}
|
| 132 |
+
</div>
|
| 133 |
+
) : null}
|
| 134 |
+
|
| 135 |
+
<div className="flex-1 overflow-y-auto p-3">
|
| 136 |
+
{sessionBlocked ? (
|
| 137 |
+
<div className="text-[12px] text-neutral-600">
|
| 138 |
+
{sessionId === null ? 'No session selected.' : 'Loading session…'}
|
| 139 |
+
</div>
|
| 140 |
+
) : (
|
| 141 |
+
<>
|
| 142 |
+
<div className="mb-3 rounded border border-neutral-800 bg-neutral-950/40 p-2 text-[11px]">
|
| 143 |
+
<div className="mb-1 flex items-center gap-2 font-semibold uppercase tracking-wider text-neutral-500">
|
| 144 |
+
Runtime binding
|
| 145 |
+
{runtimeBinding.data !== undefined ? (
|
| 146 |
+
<Badge tone={runtimeBinding.data.available ? 'green' : 'red'}>
|
| 147 |
+
{runtimeBinding.data.available ? 'available' : 'unavailable'}
|
| 148 |
+
</Badge>
|
| 149 |
+
) : null}
|
| 150 |
+
</div>
|
| 151 |
+
<div className="grid grid-cols-[80px_minmax(0,1fr)] gap-1 font-mono">
|
| 152 |
+
<span className="text-neutral-600">workspace</span>
|
| 153 |
+
<span className="break-all text-neutral-300">{runtimeBinding.data?.binding.workspaceId ?? 'loading…'}</span>
|
| 154 |
+
<span className="text-neutral-600">runtime</span>
|
| 155 |
+
<span className="break-all text-neutral-300">{runtimeBinding.data?.binding.runtimeId ?? 'loading…'}</span>
|
| 156 |
+
<span className="text-neutral-600">generation</span>
|
| 157 |
+
<span className="break-all text-neutral-300">{runtimeBinding.data?.runtime?.generation ?? 'unavailable'}</span>
|
| 158 |
+
<span className="text-neutral-600">status</span>
|
| 159 |
+
<span className="text-neutral-300">{runtimeBinding.data?.runtime?.status ?? 'unavailable'}</span>
|
| 160 |
+
<span className="text-neutral-600">capabilities</span>
|
| 161 |
+
<span className="text-neutral-300">{runtimeBinding.data?.runtime?.capabilities.join(', ') ?? 'none'}</span>
|
| 162 |
+
</div>
|
| 163 |
+
{runtimeBinding.isError ? <ErrorLine error={runtimeBinding.error} /> : null}
|
| 164 |
+
</div>
|
| 165 |
+
<PlanCard sessionId={sessionId} agentId={effectiveAgent} />
|
| 166 |
+
<ScopePanels
|
| 167 |
+
scope="agent"
|
| 168 |
+
proxyFor={proxyFor}
|
| 169 |
+
onError={(error) => noteAgentError(effectiveAgent, error)}
|
| 170 |
+
/>
|
| 171 |
+
</>
|
| 172 |
+
)}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// ---------------------------------------------------------------------------
|
| 179 |
+
// Plan lookup — derived from the message stream (`GET /sessions/{id}/history`
|
| 180 |
+
// full read + client-side `projectPlans`): the reviewed plan of one
|
| 181 |
+
// ExitPlanMode tool call, found by tool_call_id (copy it from a tool frame in
|
| 182 |
+
// the chat view), or every plan of the agent. Read-only, fetched on demand
|
| 183 |
+
// like everything else here.
|
| 184 |
+
// ---------------------------------------------------------------------------
|
| 185 |
+
|
| 186 |
+
function PlanCard({ sessionId, agentId }: { sessionId: string; agentId: string }) {
|
| 187 |
+
const { baseUrl, config } = useConnection();
|
| 188 |
+
const [toolCallId, setToolCallId] = useState('');
|
| 189 |
+
const [result, setResult] = useState<readonly PlanInfo[] | null>(null);
|
| 190 |
+
const [error, setError] = useState<unknown>(null);
|
| 191 |
+
const [loading, setLoading] = useState(false);
|
| 192 |
+
|
| 193 |
+
// A plan belongs to one agent's timeline — stale results from another
|
| 194 |
+
// session/agent are misleading, so reset on switch.
|
| 195 |
+
useEffect(() => {
|
| 196 |
+
setResult(null);
|
| 197 |
+
setError(null);
|
| 198 |
+
}, [sessionId, agentId]);
|
| 199 |
+
|
| 200 |
+
const query = async () => {
|
| 201 |
+
setLoading(true);
|
| 202 |
+
try {
|
| 203 |
+
setError(null);
|
| 204 |
+
const token = config.token.trim();
|
| 205 |
+
const messages = await fetchFullHistory({
|
| 206 |
+
baseUrl,
|
| 207 |
+
token: token === '' ? undefined : token,
|
| 208 |
+
sessionId,
|
| 209 |
+
agentId,
|
| 210 |
+
});
|
| 211 |
+
const id = toolCallId.trim();
|
| 212 |
+
setResult(projectPlans(messages, id === '' ? undefined : id));
|
| 213 |
+
} catch (error) {
|
| 214 |
+
setResult(null);
|
| 215 |
+
setError(error);
|
| 216 |
+
} finally {
|
| 217 |
+
setLoading(false);
|
| 218 |
+
}
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
return (
|
| 222 |
+
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-950/40">
|
| 223 |
+
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
| 224 |
+
<span className="text-[12px] font-medium text-neutral-200">Plan lookup</span>
|
| 225 |
+
<Badge tone="sky">{agentId}</Badge>
|
| 226 |
+
</div>
|
| 227 |
+
<div className="px-3 py-2">
|
| 228 |
+
<div className="flex gap-1.5">
|
| 229 |
+
<input
|
| 230 |
+
className="min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
| 231 |
+
placeholder="tool_call_id (empty = all plans)"
|
| 232 |
+
value={toolCallId}
|
| 233 |
+
onChange={(e) => setToolCallId(e.target.value)}
|
| 234 |
+
onKeyDown={(e) => {
|
| 235 |
+
if (e.key === 'Enter') void query();
|
| 236 |
+
}}
|
| 237 |
+
/>
|
| 238 |
+
<ActionButton disabled={loading} onClick={() => void query()}>
|
| 239 |
+
{loading ? 'Loading…' : 'Query'}
|
| 240 |
+
</ActionButton>
|
| 241 |
+
</div>
|
| 242 |
+
{error !== null ? (
|
| 243 |
+
<div className="mt-2">
|
| 244 |
+
<ErrorLine error={error} />
|
| 245 |
+
</div>
|
| 246 |
+
) : null}
|
| 247 |
+
{result !== null ? (
|
| 248 |
+
result.length === 0 ? (
|
| 249 |
+
<div className="mt-2 text-[11px] text-neutral-600 italic">no plans on this agent</div>
|
| 250 |
+
) : (
|
| 251 |
+
result.map((entry) => <PlanEntryView key={entry.toolCallId} entry={entry} />)
|
| 252 |
+
)
|
| 253 |
+
) : null}
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
function PlanEntryView({ entry }: { entry: PlanInfo }) {
|
| 260 |
+
const review = entry.review;
|
| 261 |
+
return (
|
| 262 |
+
<div className="mt-2">
|
| 263 |
+
<div className="mb-1.5 flex flex-wrap items-center gap-1.5">
|
| 264 |
+
<span className="font-mono text-[10px] text-neutral-400 select-all">
|
| 265 |
+
{entry.toolCallId}
|
| 266 |
+
</span>
|
| 267 |
+
<Badge tone="neutral">{entry.source}</Badge>
|
| 268 |
+
{review !== undefined ? (
|
| 269 |
+
<Badge
|
| 270 |
+
tone={
|
| 271 |
+
review.state === 'approved' ? 'green' : review.state === 'pending' ? 'amber' : 'red'
|
| 272 |
+
}
|
| 273 |
+
>
|
| 274 |
+
{review.state}
|
| 275 |
+
</Badge>
|
| 276 |
+
) : null}
|
| 277 |
+
<span className="font-mono text-[10px] text-neutral-500">{entry.turnId}</span>
|
| 278 |
+
</div>
|
| 279 |
+
{entry.path !== undefined ? (
|
| 280 |
+
<div className="mb-1 break-all font-mono text-[10px] text-neutral-500">{entry.path}</div>
|
| 281 |
+
) : null}
|
| 282 |
+
{review?.selectedOption !== undefined ? (
|
| 283 |
+
<div className="mb-1 text-[11px] text-neutral-400">
|
| 284 |
+
<span className="text-neutral-600">selected: </span>
|
| 285 |
+
{review.selectedOption}
|
| 286 |
+
</div>
|
| 287 |
+
) : null}
|
| 288 |
+
{review?.feedback !== undefined ? (
|
| 289 |
+
<div className="mb-1 text-[11px] text-neutral-400">
|
| 290 |
+
<span className="text-neutral-600">feedback: </span>
|
| 291 |
+
{review.feedback}
|
| 292 |
+
</div>
|
| 293 |
+
) : null}
|
| 294 |
+
{entry.options !== undefined ? (
|
| 295 |
+
<div className="mb-1 flex flex-wrap gap-1.5">
|
| 296 |
+
{entry.options.map((option) => (
|
| 297 |
+
<Badge key={option.label} tone="violet">
|
| 298 |
+
{option.label}
|
| 299 |
+
</Badge>
|
| 300 |
+
))}
|
| 301 |
+
</div>
|
| 302 |
+
) : null}
|
| 303 |
+
<pre className="max-h-72 overflow-auto rounded border border-neutral-800 bg-neutral-950 p-2 text-[11px] whitespace-pre-wrap text-neutral-300">
|
| 304 |
+
{entry.plan}
|
| 305 |
+
</pre>
|
| 306 |
+
</div>
|
| 307 |
+
);
|
| 308 |
+
}
|
apps/kimi-inspect/src/components/InteractionsCard.tsx
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Pending interactions card (approvals / questions) of one session — fetched
|
| 3 |
+
* on demand over the public REST surface (`/api/v1/sessions/{id}/approvals`
|
| 4 |
+
* and `.../questions`): the engine's interaction kernel is a process-global
|
| 5 |
+
* singleton with no debug channel, and the session `interactions` push
|
| 6 |
+
* stream went away with `/api/v2/ws`, so the card refreshes only when Load
|
| 7 |
+
* is clicked.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { useState } from 'react';
|
| 11 |
+
|
| 12 |
+
import { useConnection } from '../connection';
|
| 13 |
+
import {
|
| 14 |
+
answerQuestion,
|
| 15 |
+
decideApproval,
|
| 16 |
+
dismissQuestion,
|
| 17 |
+
listPendingApprovals,
|
| 18 |
+
listPendingQuestions,
|
| 19 |
+
type QuestionAnswerWire,
|
| 20 |
+
type QuestionWire,
|
| 21 |
+
} from '../interactions/api';
|
| 22 |
+
import { ActionButton, Badge, ErrorLine, JsonView } from '../ui';
|
| 23 |
+
|
| 24 |
+
interface PendingInteraction {
|
| 25 |
+
readonly id: string;
|
| 26 |
+
/** Known kinds: 'approval' | 'question'. */
|
| 27 |
+
readonly kind: string;
|
| 28 |
+
readonly payload: Record<string, unknown>;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function InteractionsCard({ sessionId }: { sessionId: string }) {
|
| 32 |
+
const { config, baseUrl } = useConnection();
|
| 33 |
+
const [pending, setPending] = useState<readonly PendingInteraction[]>([]);
|
| 34 |
+
const [error, setError] = useState<unknown>(null);
|
| 35 |
+
const api = { baseUrl, token: config.token, sessionId };
|
| 36 |
+
|
| 37 |
+
const reload = async () => {
|
| 38 |
+
try {
|
| 39 |
+
setError(null);
|
| 40 |
+
const [approvals, questions] = await Promise.all([
|
| 41 |
+
listPendingApprovals(api),
|
| 42 |
+
listPendingQuestions(api),
|
| 43 |
+
]);
|
| 44 |
+
setPending([
|
| 45 |
+
...approvals.map((p) => ({
|
| 46 |
+
id: p.approval_id,
|
| 47 |
+
kind: 'approval',
|
| 48 |
+
payload: {
|
| 49 |
+
toolName: p.tool_name,
|
| 50 |
+
action: p.action,
|
| 51 |
+
display: p.tool_input_display,
|
| 52 |
+
},
|
| 53 |
+
})),
|
| 54 |
+
...questions.map((p) => ({
|
| 55 |
+
id: p.question_id,
|
| 56 |
+
kind: 'question',
|
| 57 |
+
payload: p as unknown as Record<string, unknown>,
|
| 58 |
+
})),
|
| 59 |
+
]);
|
| 60 |
+
} catch (error) {
|
| 61 |
+
setError(error);
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const decide = async (id: string, decision: 'approved' | 'rejected') => {
|
| 66 |
+
try {
|
| 67 |
+
await decideApproval(api, id, decision);
|
| 68 |
+
await reload();
|
| 69 |
+
} catch (error) {
|
| 70 |
+
setError(error);
|
| 71 |
+
}
|
| 72 |
+
};
|
| 73 |
+
const answer = async (id: string, answers: Readonly<Record<string, QuestionAnswerWire>>) => {
|
| 74 |
+
try {
|
| 75 |
+
await answerQuestion(api, id, answers, 'click');
|
| 76 |
+
await reload();
|
| 77 |
+
} catch (error) {
|
| 78 |
+
setError(error);
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
const dismiss = async (id: string) => {
|
| 82 |
+
try {
|
| 83 |
+
await dismissQuestion(api, id);
|
| 84 |
+
await reload();
|
| 85 |
+
} catch (error) {
|
| 86 |
+
setError(error);
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
<div className="mb-3 rounded-lg border border-amber-900/50 bg-amber-950/20">
|
| 92 |
+
<div className="flex items-center justify-between border-b border-amber-900/40 px-3 py-2">
|
| 93 |
+
<span className="text-[12px] font-medium text-amber-200">
|
| 94 |
+
Pending interactions {pending.length > 0 ? `(${pending.length})` : ''}
|
| 95 |
+
</span>
|
| 96 |
+
<ActionButton onClick={() => void reload()}>Load</ActionButton>
|
| 97 |
+
</div>
|
| 98 |
+
<div className="px-3 py-2">
|
| 99 |
+
{error !== null ? (
|
| 100 |
+
<div className="mb-2">
|
| 101 |
+
<ErrorLine error={error} />
|
| 102 |
+
</div>
|
| 103 |
+
) : null}
|
| 104 |
+
{pending.length === 0 ? (
|
| 105 |
+
<div className="text-[11px] text-neutral-600 italic">
|
| 106 |
+
nothing pending (click Load to check)
|
| 107 |
+
</div>
|
| 108 |
+
) : (
|
| 109 |
+
pending.map((item) => (
|
| 110 |
+
<div
|
| 111 |
+
key={item.id}
|
| 112 |
+
className="mb-2 rounded border border-neutral-800 bg-neutral-950/60 p-2"
|
| 113 |
+
>
|
| 114 |
+
<div className="mb-1 flex items-center gap-2">
|
| 115 |
+
<Badge tone="amber">{item.kind}</Badge>
|
| 116 |
+
<span className="font-mono text-[10px] text-neutral-500">{item.id}</span>
|
| 117 |
+
</div>
|
| 118 |
+
{item.kind === 'approval' ? (
|
| 119 |
+
<>
|
| 120 |
+
<div className="mb-1.5 text-[11px] text-neutral-300">
|
| 121 |
+
<span className="text-neutral-500">tool </span>
|
| 122 |
+
{payloadField(item.payload, 'toolName', '?')}
|
| 123 |
+
<span className="text-neutral-500"> · </span>
|
| 124 |
+
{payloadField(item.payload, 'action', '')}
|
| 125 |
+
</div>
|
| 126 |
+
<JsonView data={item.payload['display'] ?? item.payload} />
|
| 127 |
+
<div className="mt-2 flex gap-1.5">
|
| 128 |
+
<ActionButton onClick={() => void decide(item.id, 'approved')}>
|
| 129 |
+
Approve
|
| 130 |
+
</ActionButton>
|
| 131 |
+
<ActionButton danger onClick={() => void decide(item.id, 'rejected')}>
|
| 132 |
+
Reject
|
| 133 |
+
</ActionButton>
|
| 134 |
+
</div>
|
| 135 |
+
</>
|
| 136 |
+
) : item.kind === 'question' ? (
|
| 137 |
+
<QuestionView
|
| 138 |
+
wire={item.payload as unknown as QuestionWire}
|
| 139 |
+
onAnswer={(answers) => void answer(item.id, answers)}
|
| 140 |
+
onDismiss={() => void dismiss(item.id)}
|
| 141 |
+
/>
|
| 142 |
+
) : (
|
| 143 |
+
<JsonView data={item.payload} />
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
))
|
| 147 |
+
)}
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function QuestionView({
|
| 154 |
+
wire,
|
| 155 |
+
onAnswer,
|
| 156 |
+
onDismiss,
|
| 157 |
+
}: {
|
| 158 |
+
wire: QuestionWire;
|
| 159 |
+
onAnswer: (answers: Readonly<Record<string, QuestionAnswerWire>>) => void;
|
| 160 |
+
onDismiss: () => void;
|
| 161 |
+
}) {
|
| 162 |
+
return (
|
| 163 |
+
<>
|
| 164 |
+
{wire.questions.map((q) => (
|
| 165 |
+
<div key={q.id} className="mb-1.5">
|
| 166 |
+
<div className="mb-1 text-[11px] text-neutral-300">{q.question}</div>
|
| 167 |
+
<div className="flex flex-wrap gap-1.5">
|
| 168 |
+
{q.options.map((opt) => (
|
| 169 |
+
<ActionButton
|
| 170 |
+
key={opt.id}
|
| 171 |
+
onClick={() => onAnswer({ [q.id]: { kind: 'single', option_id: opt.id } })}
|
| 172 |
+
>
|
| 173 |
+
{opt.label}
|
| 174 |
+
</ActionButton>
|
| 175 |
+
))}
|
| 176 |
+
{q.allow_other === false ? null : (
|
| 177 |
+
<ActionButton
|
| 178 |
+
onClick={() => {
|
| 179 |
+
const raw = window.prompt(q.question);
|
| 180 |
+
if (raw !== null) onAnswer({ [q.id]: { kind: 'other', text: raw } });
|
| 181 |
+
}}
|
| 182 |
+
>
|
| 183 |
+
Other…
|
| 184 |
+
</ActionButton>
|
| 185 |
+
)}
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
))}
|
| 189 |
+
{wire.questions.length === 0 ? <JsonView data={wire} /> : null}
|
| 190 |
+
<div className="mt-1.5">
|
| 191 |
+
<ActionButton danger onClick={onDismiss}>
|
| 192 |
+
Dismiss
|
| 193 |
+
</ActionButton>
|
| 194 |
+
</div>
|
| 195 |
+
</>
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/**
|
| 200 |
+
* Render a wire payload field as display text: strings pass through,
|
| 201 |
+
* numbers/booleans are stringified, anything else (or missing) falls back —
|
| 202 |
+
* never "[object Object]".
|
| 203 |
+
*/
|
| 204 |
+
function payloadField(payload: Record<string, unknown>, key: string, fallback: string): string {
|
| 205 |
+
const value = payload[key];
|
| 206 |
+
if (typeof value === 'string') return value;
|
| 207 |
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
| 208 |
+
return fallback;
|
| 209 |
+
}
|
apps/kimi-inspect/src/components/ModelCatalogView.tsx
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Model Catalog view — providers with their models and the default marker:
|
| 3 |
+
*
|
| 4 |
+
* left: every configured model (provider-grouped), its highlight synced
|
| 5 |
+
* both ways with the center column — scrolling the center moves the
|
| 6 |
+
* highlight (scrollspy), clicking an entry jumps the center to that
|
| 7 |
+
* model;
|
| 8 |
+
* center: one section per model with ping and session creation actions.
|
| 9 |
+
*
|
| 10 |
+
* All data goes through the channel layer — `IModelCatalog` +
|
| 11 |
+
* `IModelService` for the list — over the `/api/v1/debug` RPC surface.
|
| 12 |
+
* There is no live event push; the queries refresh on a slow poll.
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile';
|
| 16 |
+
import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager';
|
| 17 |
+
import type { TokenUsage } from '@moonshot-ai/agent-core-v2/human/llm/usage';
|
| 18 |
+
import {
|
| 19 |
+
IModelCatalog,
|
| 20 |
+
type ModelCatalogItem,
|
| 21 |
+
type ModelPingResult,
|
| 22 |
+
type ProviderCatalogItem,
|
| 23 |
+
} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog';
|
| 24 |
+
import { IModelService } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model';
|
| 25 |
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
| 26 |
+
import { useEffect, useRef, useState } from 'react';
|
| 27 |
+
|
| 28 |
+
import { useConnection } from '../connection';
|
| 29 |
+
import { ActionButton, Badge, ErrorLine, errorMessage } from '../ui';
|
| 30 |
+
|
| 31 |
+
interface FlatEntry {
|
| 32 |
+
readonly item: ModelCatalogItem;
|
| 33 |
+
readonly provider?: ProviderCatalogItem;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export function ModelCatalogView({
|
| 37 |
+
onOpenSession,
|
| 38 |
+
}: {
|
| 39 |
+
readonly onOpenSession: (sessionId: string) => void;
|
| 40 |
+
}) {
|
| 41 |
+
const { klient } = useConnection();
|
| 42 |
+
const queryClient = useQueryClient();
|
| 43 |
+
|
| 44 |
+
const providers = useQuery({
|
| 45 |
+
queryKey: ['modelCatalog', 'providers'],
|
| 46 |
+
queryFn: () => klient.core(IModelCatalog).listProviders(),
|
| 47 |
+
refetchInterval: 15_000,
|
| 48 |
+
});
|
| 49 |
+
const models = useQuery({
|
| 50 |
+
queryKey: ['modelCatalog', 'models'],
|
| 51 |
+
queryFn: () => klient.core(IModelCatalog).listModels(),
|
| 52 |
+
refetchInterval: 15_000,
|
| 53 |
+
});
|
| 54 |
+
// Raw records carry the structured `providerId` grouping fallback.
|
| 55 |
+
const records = useQuery({
|
| 56 |
+
queryKey: ['modelCatalog', 'records'],
|
| 57 |
+
queryFn: () => klient.core(IModelService).list(),
|
| 58 |
+
refetchInterval: 15_000,
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
const providerList = providers.data ?? [];
|
| 62 |
+
const items = models.data ?? [];
|
| 63 |
+
const recordMap = records.data ?? {};
|
| 64 |
+
|
| 65 |
+
// Flatten the provider grouping into one ordered model list (listed
|
| 66 |
+
// providers first, then models whose group matches no listed provider).
|
| 67 |
+
const groupKeyOf = (item: ModelCatalogItem): string => {
|
| 68 |
+
const record = recordMap[item.model];
|
| 69 |
+
return record?.providerId ?? record?.provider ?? item.provider;
|
| 70 |
+
};
|
| 71 |
+
const byGroup = new Map<string, ModelCatalogItem[]>();
|
| 72 |
+
for (const item of items) {
|
| 73 |
+
const key = groupKeyOf(item);
|
| 74 |
+
const group = byGroup.get(key) ?? [];
|
| 75 |
+
group.push(item);
|
| 76 |
+
byGroup.set(key, group);
|
| 77 |
+
}
|
| 78 |
+
const listedIds = new Set(providerList.map((p) => p.id));
|
| 79 |
+
const extraGroups = [...byGroup.keys()].filter((key) => !listedIds.has(key));
|
| 80 |
+
const flatEntries: FlatEntry[] = [
|
| 81 |
+
...providerList.flatMap((provider) =>
|
| 82 |
+
(byGroup.get(provider.id) ?? []).map((item) => ({ item, provider }) as const),
|
| 83 |
+
),
|
| 84 |
+
...extraGroups.flatMap((key) => (byGroup.get(key) ?? []).map((item) => ({ item }) as const)),
|
| 85 |
+
];
|
| 86 |
+
|
| 87 |
+
// --- two-way sync between the left list and the center scroll ----------
|
| 88 |
+
const [activeId, setActiveId] = useState<string | null>(null);
|
| 89 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 90 |
+
const listRef = useRef<HTMLDivElement>(null);
|
| 91 |
+
const sectionRefs = useRef(new Map<string, HTMLElement>());
|
| 92 |
+
const itemRefs = useRef(new Map<string, HTMLElement>());
|
| 93 |
+
|
| 94 |
+
// Default the highlight to the first model once data arrives, and follow
|
| 95 |
+
// the list when the highlighted model disappears (config changed).
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
if (flatEntries.length === 0) return;
|
| 98 |
+
if (activeId === null || !flatEntries.some((entry) => entry.item.model === activeId)) {
|
| 99 |
+
setActiveId(flatEntries[0]!.item.model);
|
| 100 |
+
}
|
| 101 |
+
}, [activeId, flatEntries]);
|
| 102 |
+
|
| 103 |
+
// Center scroll → left highlight (scrollspy). Computed synchronously — a
|
| 104 |
+
// rAF throttle would stall in background tabs and lag real scrolling; the
|
| 105 |
+
// walk over the entry list is trivial at this scale.
|
| 106 |
+
const handleCenterScroll = () => {
|
| 107 |
+
const container = scrollRef.current;
|
| 108 |
+
if (container === null) return;
|
| 109 |
+
const top = container.scrollTop + 12;
|
| 110 |
+
let current: string | null = null;
|
| 111 |
+
for (const entry of flatEntries) {
|
| 112 |
+
const el = sectionRefs.current.get(entry.item.model);
|
| 113 |
+
if (el === undefined) continue;
|
| 114 |
+
if (el.offsetTop <= top) current = entry.item.model;
|
| 115 |
+
else break;
|
| 116 |
+
}
|
| 117 |
+
if (current !== null) setActiveId((prev) => (prev === current ? prev : current));
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
// Left highlight → keep the highlighted entry visible inside the left list
|
| 121 |
+
// (manual scroll: scrollIntoView would also move the center column).
|
| 122 |
+
useEffect(() => {
|
| 123 |
+
if (activeId === null) return;
|
| 124 |
+
const list = listRef.current;
|
| 125 |
+
const el = itemRefs.current.get(activeId);
|
| 126 |
+
if (list === null || el === undefined) return;
|
| 127 |
+
const top = el.offsetTop;
|
| 128 |
+
const bottom = top + el.offsetHeight;
|
| 129 |
+
if (top < list.scrollTop) list.scrollTop = top;
|
| 130 |
+
else if (bottom > list.scrollTop + list.clientHeight) {
|
| 131 |
+
list.scrollTop = bottom - list.clientHeight;
|
| 132 |
+
}
|
| 133 |
+
}, [activeId]);
|
| 134 |
+
|
| 135 |
+
const jumpTo = (modelId: string) => {
|
| 136 |
+
setActiveId(modelId);
|
| 137 |
+
// 'instant', not 'smooth': smooth scrolling is frame-driven and stalls in
|
| 138 |
+
// occluded tabs, leaving the center column stranded mid-jump.
|
| 139 |
+
sectionRefs.current.get(modelId)?.scrollIntoView({ behavior: 'instant', block: 'start' });
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
const loading = providers.isLoading || models.isLoading || records.isLoading;
|
| 143 |
+
const error = providers.error ?? models.error ?? records.error;
|
| 144 |
+
|
| 145 |
+
return (
|
| 146 |
+
<div className="flex min-h-0 flex-1 flex-col">
|
| 147 |
+
<div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
|
| 148 |
+
<span className="text-[12px] font-semibold uppercase tracking-wider text-neutral-400">
|
| 149 |
+
Model Catalog
|
| 150 |
+
</span>
|
| 151 |
+
<span className="text-[11px] text-neutral-600">
|
| 152 |
+
{providerList.length} providers · {items.length} models
|
| 153 |
+
</span>
|
| 154 |
+
<div className="flex-1" />
|
| 155 |
+
<ActionButton onClick={() => queryClient.invalidateQueries({ queryKey: ['modelCatalog'] })}>
|
| 156 |
+
Refresh
|
| 157 |
+
</ActionButton>
|
| 158 |
+
</div>
|
| 159 |
+
{error !== null ? (
|
| 160 |
+
<div className="px-4 py-2">
|
| 161 |
+
<ErrorLine error={error} />
|
| 162 |
+
</div>
|
| 163 |
+
) : null}
|
| 164 |
+
{loading ? <div className="px-4 py-2 text-[11px] text-neutral-600">loading…</div> : null}
|
| 165 |
+
{!loading && flatEntries.length === 0 ? (
|
| 166 |
+
<div className="px-4 py-2 text-[11px] text-neutral-600">
|
| 167 |
+
no providers or models configured
|
| 168 |
+
</div>
|
| 169 |
+
) : null}
|
| 170 |
+
<div className="flex min-h-0 flex-1">
|
| 171 |
+
{/* left: the model list */}
|
| 172 |
+
<div
|
| 173 |
+
ref={listRef}
|
| 174 |
+
className="relative w-72 shrink-0 overflow-y-auto border-r border-neutral-800"
|
| 175 |
+
>
|
| 176 |
+
<LeftList entries={flatEntries} activeId={activeId} onJump={jumpTo} itemRefs={itemRefs} />
|
| 177 |
+
</div>
|
| 178 |
+
{/* center: one section per model */}
|
| 179 |
+
<div
|
| 180 |
+
ref={scrollRef}
|
| 181 |
+
className="relative min-w-0 flex-1 overflow-y-auto"
|
| 182 |
+
onScroll={handleCenterScroll}
|
| 183 |
+
>
|
| 184 |
+
{flatEntries.map((entry) => (
|
| 185 |
+
<ModelSection
|
| 186 |
+
key={entry.item.model}
|
| 187 |
+
entry={entry}
|
| 188 |
+
onOpenSession={onOpenSession}
|
| 189 |
+
registerRef={(el) => {
|
| 190 |
+
if (el === null) sectionRefs.current.delete(entry.item.model);
|
| 191 |
+
else sectionRefs.current.set(entry.item.model, el);
|
| 192 |
+
}}
|
| 193 |
+
/>
|
| 194 |
+
))}
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// ---------------------------------------------------------------------------
|
| 202 |
+
// left column
|
| 203 |
+
// ---------------------------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
function LeftList({
|
| 206 |
+
entries,
|
| 207 |
+
activeId,
|
| 208 |
+
onJump,
|
| 209 |
+
itemRefs,
|
| 210 |
+
}: {
|
| 211 |
+
readonly entries: readonly FlatEntry[];
|
| 212 |
+
readonly activeId: string | null;
|
| 213 |
+
readonly onJump: (modelId: string) => void;
|
| 214 |
+
readonly itemRefs: React.RefObject<Map<string, HTMLElement>>;
|
| 215 |
+
}) {
|
| 216 |
+
let lastGroup: string | undefined;
|
| 217 |
+
return (
|
| 218 |
+
<div className="py-1">
|
| 219 |
+
{entries.map((entry) => {
|
| 220 |
+
const groupId = entry.provider?.id ?? '(no provider)';
|
| 221 |
+
const header = groupId !== lastGroup ? groupId : undefined;
|
| 222 |
+
lastGroup = groupId;
|
| 223 |
+
const isDefault = entry.provider?.default_model === entry.item.model;
|
| 224 |
+
const active = entry.item.model === activeId;
|
| 225 |
+
return (
|
| 226 |
+
<div key={entry.item.model}>
|
| 227 |
+
{header !== undefined ? (
|
| 228 |
+
<div className="flex items-center gap-1.5 px-3 pb-1 pt-2.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
|
| 229 |
+
<span className="truncate">{header}</span>
|
| 230 |
+
{entry.provider !== undefined ? (
|
| 231 |
+
<Badge
|
| 232 |
+
tone={
|
| 233 |
+
entry.provider.status === 'connected'
|
| 234 |
+
? 'green'
|
| 235 |
+
: entry.provider.status === 'error'
|
| 236 |
+
? 'red'
|
| 237 |
+
: 'amber'
|
| 238 |
+
}
|
| 239 |
+
>
|
| 240 |
+
{entry.provider.status}
|
| 241 |
+
</Badge>
|
| 242 |
+
) : null}
|
| 243 |
+
</div>
|
| 244 |
+
) : null}
|
| 245 |
+
<div
|
| 246 |
+
ref={(el) => {
|
| 247 |
+
if (el === null) itemRefs.current.delete(entry.item.model);
|
| 248 |
+
else itemRefs.current.set(entry.item.model, el);
|
| 249 |
+
}}
|
| 250 |
+
className={`cursor-pointer px-3 py-1.5 ${
|
| 251 |
+
active ? 'bg-sky-950/60' : 'hover:bg-neutral-800/60'
|
| 252 |
+
}`}
|
| 253 |
+
onClick={() => {
|
| 254 |
+
onJump(entry.item.model);
|
| 255 |
+
}}
|
| 256 |
+
>
|
| 257 |
+
<div className="flex items-center gap-1.5">
|
| 258 |
+
<span
|
| 259 |
+
className={`min-w-0 flex-1 truncate font-mono text-[11px] ${
|
| 260 |
+
active ? 'text-neutral-100' : 'text-neutral-300'
|
| 261 |
+
}`}
|
| 262 |
+
title={entry.item.model}
|
| 263 |
+
>
|
| 264 |
+
{entry.item.model}
|
| 265 |
+
</span>
|
| 266 |
+
{isDefault ? <Badge tone="sky">default</Badge> : null}
|
| 267 |
+
</div>
|
| 268 |
+
{entry.item.display_name !== undefined &&
|
| 269 |
+
entry.item.display_name !== entry.item.model ? (
|
| 270 |
+
<div className="truncate text-[10px] text-neutral-600">
|
| 271 |
+
{entry.item.display_name}
|
| 272 |
+
</div>
|
| 273 |
+
) : null}
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
);
|
| 277 |
+
})}
|
| 278 |
+
</div>
|
| 279 |
+
);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// ---------------------------------------------------------------------------
|
| 283 |
+
// center column
|
| 284 |
+
// ---------------------------------------------------------------------------
|
| 285 |
+
|
| 286 |
+
function ModelSection({
|
| 287 |
+
entry,
|
| 288 |
+
onOpenSession,
|
| 289 |
+
registerRef,
|
| 290 |
+
}: {
|
| 291 |
+
readonly entry: FlatEntry;
|
| 292 |
+
readonly onOpenSession: (sessionId: string) => void;
|
| 293 |
+
readonly registerRef: (el: HTMLElement | null) => void;
|
| 294 |
+
}) {
|
| 295 |
+
const { klient, baseUrl, config } = useConnection();
|
| 296 |
+
const { item, provider } = entry;
|
| 297 |
+
const [ping, setPing] = useState<
|
| 298 |
+
| { readonly status: 'idle' | 'running' }
|
| 299 |
+
| { readonly status: 'done'; readonly result: ModelPingResult }
|
| 300 |
+
>({ status: 'idle' });
|
| 301 |
+
const [creating, setCreating] = useState(false);
|
| 302 |
+
const [sessionError, setSessionError] = useState<unknown>(null);
|
| 303 |
+
|
| 304 |
+
const runPing = async () => {
|
| 305 |
+
setPing({ status: 'running' });
|
| 306 |
+
try {
|
| 307 |
+
const result = await klient.core(IModelCatalog).ping(item.model);
|
| 308 |
+
setPing({ status: 'done', result });
|
| 309 |
+
} catch (error) {
|
| 310 |
+
setPing({
|
| 311 |
+
status: 'done',
|
| 312 |
+
result: { ok: false, durationMs: 0, error: errorMessage(error) },
|
| 313 |
+
});
|
| 314 |
+
}
|
| 315 |
+
};
|
| 316 |
+
|
| 317 |
+
// Session creation itself is v1 REST (klient is v2-only); resume + model
|
| 318 |
+
// selection then go through the channel layer before opening the chat tab.
|
| 319 |
+
const createSession = async () => {
|
| 320 |
+
const cwd = window.prompt('Working directory for the new session:', '');
|
| 321 |
+
if (cwd === null || cwd.trim() === '') return;
|
| 322 |
+
setCreating(true);
|
| 323 |
+
setSessionError(null);
|
| 324 |
+
try {
|
| 325 |
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
| 326 |
+
if (config.token.trim() !== '') headers['authorization'] = `Bearer ${config.token.trim()}`;
|
| 327 |
+
const res = await fetch(`${baseUrl}/api/v1/sessions`, {
|
| 328 |
+
method: 'POST',
|
| 329 |
+
headers,
|
| 330 |
+
body: JSON.stringify({ metadata: { cwd: cwd.trim() } }),
|
| 331 |
+
});
|
| 332 |
+
const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } };
|
| 333 |
+
if (envelope.code !== 0) throw new Error(envelope.msg);
|
| 334 |
+
const sessionId = envelope.data.id;
|
| 335 |
+
await klient.core(ISessionManager).resume(sessionId);
|
| 336 |
+
await klient
|
| 337 |
+
.session(sessionId)
|
| 338 |
+
.agent('main')
|
| 339 |
+
.service(IAgentProfileService)
|
| 340 |
+
.setModel(item.model);
|
| 341 |
+
onOpenSession(sessionId);
|
| 342 |
+
} catch (error) {
|
| 343 |
+
setSessionError(error);
|
| 344 |
+
} finally {
|
| 345 |
+
setCreating(false);
|
| 346 |
+
}
|
| 347 |
+
};
|
| 348 |
+
|
| 349 |
+
return (
|
| 350 |
+
<section ref={registerRef} className="border-b border-neutral-800 px-4 py-3">
|
| 351 |
+
<header className="mb-1 flex flex-wrap items-center gap-2">
|
| 352 |
+
<span className="font-mono text-[12px] font-semibold text-neutral-100">{item.model}</span>
|
| 353 |
+
{provider?.default_model === item.model ? <Badge tone="sky">default</Badge> : null}
|
| 354 |
+
<span className="text-[10px] text-neutral-600">{provider?.id ?? '(no provider)'}</span>
|
| 355 |
+
<ActionButton disabled={ping.status === 'running'} onClick={runPing}>
|
| 356 |
+
{ping.status === 'running' ? 'pinging…' : 'Ping'}
|
| 357 |
+
</ActionButton>
|
| 358 |
+
<ActionButton disabled={creating} onClick={createSession}>
|
| 359 |
+
{creating ? 'creating…' : '+ Session'}
|
| 360 |
+
</ActionButton>
|
| 361 |
+
<div className="flex-1" />
|
| 362 |
+
{(item.capabilities ?? []).map((capability) => (
|
| 363 |
+
<Badge key={capability}>{capability}</Badge>
|
| 364 |
+
))}
|
| 365 |
+
{item.default_effort !== undefined ? (
|
| 366 |
+
<Badge tone="neutral">effort: {item.default_effort}</Badge>
|
| 367 |
+
) : null}
|
| 368 |
+
<span className="shrink-0 font-mono text-[11px] text-neutral-500">
|
| 369 |
+
{formatContextSize(item.max_context_size)}
|
| 370 |
+
</span>
|
| 371 |
+
</header>
|
| 372 |
+
{ping.status === 'done' ? (
|
| 373 |
+
<div className="mb-1 flex flex-wrap items-center gap-2 border border-neutral-800/60 bg-neutral-900/50 px-2 py-1">
|
| 374 |
+
{ping.result.ok ? (
|
| 375 |
+
<>
|
| 376 |
+
<Badge tone="green">pong · {ping.result.durationMs}ms</Badge>
|
| 377 |
+
{ping.result.text !== undefined && ping.result.text !== '' ? (
|
| 378 |
+
<span className="text-[11px] text-neutral-300">{ping.result.text}</span>
|
| 379 |
+
) : null}
|
| 380 |
+
{ping.result.usage !== undefined ? (
|
| 381 |
+
<span className="text-[10px] text-neutral-600">{usageLine(ping.result.usage)}</span>
|
| 382 |
+
) : null}
|
| 383 |
+
{ping.result.finishReason !== undefined ? (
|
| 384 |
+
<span className="text-[10px] text-neutral-600">{ping.result.finishReason}</span>
|
| 385 |
+
) : null}
|
| 386 |
+
</>
|
| 387 |
+
) : (
|
| 388 |
+
<>
|
| 389 |
+
<Badge tone="red">ping failed · {ping.result.durationMs}ms</Badge>
|
| 390 |
+
<span className="text-[11px] text-red-400">{ping.result.error}</span>
|
| 391 |
+
</>
|
| 392 |
+
)}
|
| 393 |
+
</div>
|
| 394 |
+
) : null}
|
| 395 |
+
{sessionError !== null ? <ErrorLine error={sessionError} /> : null}
|
| 396 |
+
</section>
|
| 397 |
+
);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
function usageLine(usage: TokenUsage): string {
|
| 401 |
+
const input = usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation;
|
| 402 |
+
return `in ${input} · out ${usage.output}`;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
function formatContextSize(size: number): string {
|
| 406 |
+
if (size <= 0) return '—';
|
| 407 |
+
if (size >= 1_000_000) {
|
| 408 |
+
const millions = size / 1_000_000;
|
| 409 |
+
return `${millions.toFixed(Number.isInteger(millions) ? 0 : 1)}M ctx`;
|
| 410 |
+
}
|
| 411 |
+
if (size >= 1_000) return `${Math.round(size / 1_000)}k ctx`;
|
| 412 |
+
return `${size} ctx`;
|
| 413 |
+
}
|
apps/kimi-inspect/src/components/NavRail.tsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Left icon rail — the app-level view switcher. Icon-only by design; view
|
| 3 |
+
* names live in tooltips. Adding a view is one `VIEWS` entry here plus its
|
| 4 |
+
* render branch in `App`.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import type { ReactNode } from 'react';
|
| 8 |
+
|
| 9 |
+
export type AppView =
|
| 10 |
+
| 'chat'
|
| 11 |
+
| 'search'
|
| 12 |
+
| 'models'
|
| 13 |
+
| 'services'
|
| 14 |
+
| 'workspace'
|
| 15 |
+
| 'suggest'
|
| 16 |
+
| 'bash'
|
| 17 |
+
| 'di';
|
| 18 |
+
|
| 19 |
+
interface ViewDef {
|
| 20 |
+
readonly id: AppView;
|
| 21 |
+
readonly title: string;
|
| 22 |
+
readonly icon: ReactNode;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const iconProps = {
|
| 26 |
+
width: 18,
|
| 27 |
+
height: 18,
|
| 28 |
+
viewBox: '0 0 24 24',
|
| 29 |
+
fill: 'none',
|
| 30 |
+
stroke: 'currentColor',
|
| 31 |
+
strokeWidth: 2,
|
| 32 |
+
strokeLinecap: 'round',
|
| 33 |
+
strokeLinejoin: 'round',
|
| 34 |
+
} as const;
|
| 35 |
+
|
| 36 |
+
const VIEWS: readonly ViewDef[] = [
|
| 37 |
+
{
|
| 38 |
+
id: 'chat',
|
| 39 |
+
title: 'Chat',
|
| 40 |
+
icon: (
|
| 41 |
+
<svg {...iconProps}>
|
| 42 |
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
| 43 |
+
</svg>
|
| 44 |
+
),
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: 'search',
|
| 48 |
+
title: 'Search',
|
| 49 |
+
icon: (
|
| 50 |
+
<svg {...iconProps}>
|
| 51 |
+
<circle cx="11" cy="11" r="8" />
|
| 52 |
+
<path d="m21 21-4.3-4.3" />
|
| 53 |
+
</svg>
|
| 54 |
+
),
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
id: 'models',
|
| 58 |
+
title: 'Model Catalog',
|
| 59 |
+
icon: (
|
| 60 |
+
<svg {...iconProps}>
|
| 61 |
+
<path d="M12 2 2 7l10 5 10-5-10-5z" />
|
| 62 |
+
<path d="m2 17 10 5 10-5" />
|
| 63 |
+
<path d="m2 12 10 5 10-5" />
|
| 64 |
+
</svg>
|
| 65 |
+
),
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
id: 'services',
|
| 69 |
+
title: 'App Services',
|
| 70 |
+
icon: (
|
| 71 |
+
<svg {...iconProps}>
|
| 72 |
+
<rect x="2" y="2" width="20" height="8" rx="2" />
|
| 73 |
+
<rect x="2" y="14" width="20" height="8" rx="2" />
|
| 74 |
+
<line x1="6" y1="6" x2="6.01" y2="6" />
|
| 75 |
+
<line x1="6" y1="18" x2="6.01" y2="18" />
|
| 76 |
+
</svg>
|
| 77 |
+
),
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
id: 'workspace',
|
| 81 |
+
title: 'Workspace Runtime',
|
| 82 |
+
icon: (
|
| 83 |
+
<svg {...iconProps}>
|
| 84 |
+
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
| 85 |
+
</svg>
|
| 86 |
+
),
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
id: 'suggest',
|
| 90 |
+
title: 'Filesystem Suggest',
|
| 91 |
+
icon: (
|
| 92 |
+
<svg {...iconProps}>
|
| 93 |
+
<path d="M4 4h6l2 2h8v14H4z" />
|
| 94 |
+
<path d="m9 14 2 2 4-4" />
|
| 95 |
+
</svg>
|
| 96 |
+
),
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
id: 'bash',
|
| 100 |
+
title: 'Bash Parser',
|
| 101 |
+
icon: (
|
| 102 |
+
<svg {...iconProps}>
|
| 103 |
+
<polyline points="4 17 10 11 4 5" />
|
| 104 |
+
<line x1="12" y1="19" x2="20" y2="19" />
|
| 105 |
+
</svg>
|
| 106 |
+
),
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
id: 'di',
|
| 110 |
+
title: 'DI',
|
| 111 |
+
icon: (
|
| 112 |
+
<svg {...iconProps}>
|
| 113 |
+
<circle cx="18" cy="5" r="3" />
|
| 114 |
+
<circle cx="6" cy="12" r="3" />
|
| 115 |
+
<circle cx="18" cy="19" r="3" />
|
| 116 |
+
<line x1="8.6" y1="10.5" x2="15.4" y2="6.5" />
|
| 117 |
+
<line x1="8.6" y1="13.5" x2="15.4" y2="17.5" />
|
| 118 |
+
</svg>
|
| 119 |
+
),
|
| 120 |
+
},
|
| 121 |
+
];
|
| 122 |
+
|
| 123 |
+
export function NavRail({
|
| 124 |
+
view,
|
| 125 |
+
onChange,
|
| 126 |
+
}: {
|
| 127 |
+
readonly view: AppView;
|
| 128 |
+
readonly onChange: (view: AppView) => void;
|
| 129 |
+
}) {
|
| 130 |
+
return (
|
| 131 |
+
<nav className="flex w-11 shrink-0 flex-col items-center gap-1 border-r border-neutral-800 py-2">
|
| 132 |
+
{VIEWS.map((v) => (
|
| 133 |
+
<button
|
| 134 |
+
key={v.id}
|
| 135 |
+
type="button"
|
| 136 |
+
title={v.title}
|
| 137 |
+
aria-label={v.title}
|
| 138 |
+
onClick={() => {
|
| 139 |
+
onChange(v.id);
|
| 140 |
+
}}
|
| 141 |
+
className={`rounded-md p-2 transition-colors ${
|
| 142 |
+
view === v.id
|
| 143 |
+
? 'bg-neutral-800 text-sky-400'
|
| 144 |
+
: 'text-neutral-500 hover:bg-neutral-800/60 hover:text-neutral-300'
|
| 145 |
+
}`}
|
| 146 |
+
>
|
| 147 |
+
{v.icon}
|
| 148 |
+
</button>
|
| 149 |
+
))}
|
| 150 |
+
</nav>
|
| 151 |
+
);
|
| 152 |
+
}
|
apps/kimi-inspect/src/components/RightPanel.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Right dock — the single right-hand column of the chat view. Merges what
|
| 3 |
+
* used to be three separate columns (the transcript audit panel docked
|
| 4 |
+
* inside the chat view, the agent inspector on the far right, and the
|
| 5 |
+
* session pane next to the session list) into one tabbed column: `Audit`
|
| 6 |
+
* replays how the visible transcript store was built, entry by entry;
|
| 7 |
+
* `Agent` hosts the agent switcher, the Plan lookup card, and the agent
|
| 8 |
+
* Service panels; `State` reads the active agent's registered plain-data
|
| 9 |
+
* state through `IAgentStateService.snapshot()`; `Session` embeds
|
| 10 |
+
* `SessionPane` (session Services / State tabs). Tabs switch with `hidden`
|
| 11 |
+
* instead of unmounting, so panel-local state (the audit timeline position,
|
| 12 |
+
* Plan lookup input/results, expanded Service panels, the state tree's open
|
| 13 |
+
* rows) survives tab switches.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { IAgentStateService } from '@moonshot-ai/agent-core-v2/agent/state/agentState';
|
| 17 |
+
import { useState } from 'react';
|
| 18 |
+
|
| 19 |
+
import type { AuditTrail } from '../audit/trail';
|
| 20 |
+
import { useConnection } from '../connection';
|
| 21 |
+
import { Badge } from '../ui';
|
| 22 |
+
import { AuditPanel } from './audit/AuditPanel';
|
| 23 |
+
import { Inspector } from './Inspector';
|
| 24 |
+
import { SessionPane } from './SessionPane';
|
| 25 |
+
import { StateCard } from './StateCard';
|
| 26 |
+
|
| 27 |
+
type Tab = 'audit' | 'agent' | 'state' | 'session';
|
| 28 |
+
|
| 29 |
+
export function RightPanel({
|
| 30 |
+
sessionId,
|
| 31 |
+
agentId,
|
| 32 |
+
onAgentChange,
|
| 33 |
+
ready,
|
| 34 |
+
trail,
|
| 35 |
+
}: {
|
| 36 |
+
sessionId: string | null;
|
| 37 |
+
agentId: string;
|
| 38 |
+
onAgentChange: (agentId: string) => void;
|
| 39 |
+
ready: boolean;
|
| 40 |
+
/** The chat view's audit trail; null until its transcript channel exists. */
|
| 41 |
+
trail: AuditTrail | null;
|
| 42 |
+
}) {
|
| 43 |
+
const { klient } = useConnection();
|
| 44 |
+
const [tab, setTab] = useState<Tab>('audit');
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div className="flex h-full w-[440px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30">
|
| 48 |
+
<div className="flex border-b border-neutral-800 text-[11px]">
|
| 49 |
+
{(['audit', 'agent', 'state', 'session'] as const).map((t) => (
|
| 50 |
+
<button
|
| 51 |
+
key={t}
|
| 52 |
+
className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
|
| 53 |
+
tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
|
| 54 |
+
}`}
|
| 55 |
+
onClick={() => setTab(t)}
|
| 56 |
+
>
|
| 57 |
+
{t === 'audit' ? 'Audit' : t === 'agent' ? 'Agent' : t === 'state' ? 'State' : 'Session'}
|
| 58 |
+
</button>
|
| 59 |
+
))}
|
| 60 |
+
</div>
|
| 61 |
+
<div className={tab === 'audit' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
|
| 62 |
+
{trail !== null ? (
|
| 63 |
+
<AuditPanel trail={trail} />
|
| 64 |
+
) : (
|
| 65 |
+
<div className="p-3 text-[12px] text-neutral-600">
|
| 66 |
+
{sessionId === null
|
| 67 |
+
? 'No session selected.'
|
| 68 |
+
: 'Loading transcript — the audit trail appears once the channel is up.'}
|
| 69 |
+
</div>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
<div className={tab === 'agent' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
|
| 73 |
+
<Inspector
|
| 74 |
+
sessionId={sessionId}
|
| 75 |
+
agentId={agentId}
|
| 76 |
+
onAgentChange={onAgentChange}
|
| 77 |
+
ready={ready}
|
| 78 |
+
/>
|
| 79 |
+
</div>
|
| 80 |
+
<div className={tab === 'state' ? 'min-h-0 flex-1 overflow-y-auto' : 'hidden'}>
|
| 81 |
+
<div className="p-3">
|
| 82 |
+
{sessionId === null || !ready ? (
|
| 83 |
+
<div className="text-[12px] text-neutral-600">
|
| 84 |
+
{sessionId === null ? 'No session selected.' : 'Loading session…'}
|
| 85 |
+
</div>
|
| 86 |
+
) : (
|
| 87 |
+
<StateCard
|
| 88 |
+
id={`${sessionId}/${agentId}`}
|
| 89 |
+
queryKey={['agentState', sessionId, agentId]}
|
| 90 |
+
title="Agent state"
|
| 91 |
+
label="agentStateService"
|
| 92 |
+
badge={<Badge tone="sky">{agentId}</Badge>}
|
| 93 |
+
fetchSnapshot={() =>
|
| 94 |
+
klient.session(sessionId).agent(agentId).service(IAgentStateService).snapshot()
|
| 95 |
+
}
|
| 96 |
+
/>
|
| 97 |
+
)}
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
<div className={tab === 'session' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
|
| 101 |
+
<SessionPane sessionId={sessionId} ready={ready} />
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
apps/kimi-inspect/src/components/SearchView.tsx
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Search view — the global cross-session message search (`POST /api/v1/search`).
|
| 3 |
+
* Full width like the other non-chat views. Results are pageable (Load more);
|
| 4 |
+
* clicking a hit hands it to the app shell (`onOpenResult`), which switches
|
| 5 |
+
* to the chat view at the hit's session / agent / turn / step.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { useState } from 'react';
|
| 9 |
+
|
| 10 |
+
import { useConnection } from '../connection';
|
| 11 |
+
import { fetchSearchPage, type SearchHit, type SearchIndexState } from '../search/api';
|
| 12 |
+
import { ActionButton, Badge, ErrorLine, relTime } from '../ui';
|
| 13 |
+
|
| 14 |
+
type RoleFilter = 'all' | 'user' | 'assistant' | 'title';
|
| 15 |
+
type SortOrder = 'score' | 'time_desc' | 'time_asc';
|
| 16 |
+
type SearchMode = 'terms' | 'literal';
|
| 17 |
+
|
| 18 |
+
const PAGE_SIZE = 20;
|
| 19 |
+
|
| 20 |
+
interface ExecutedSearch {
|
| 21 |
+
readonly query: string;
|
| 22 |
+
readonly role?: 'user' | 'assistant' | 'title' | undefined;
|
| 23 |
+
readonly sort: SortOrder;
|
| 24 |
+
readonly mode: SearchMode;
|
| 25 |
+
readonly items: readonly SearchHit[];
|
| 26 |
+
readonly hasMore: boolean;
|
| 27 |
+
readonly pageToken?: string | undefined;
|
| 28 |
+
readonly incomplete?: 'candidate_cap' | undefined;
|
| 29 |
+
readonly source?: 'live' | 'index' | undefined;
|
| 30 |
+
readonly indexState: SearchIndexState;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export function SearchView({ onOpenResult }: { onOpenResult: (hit: SearchHit) => void }) {
|
| 34 |
+
const { baseUrl, config } = useConnection();
|
| 35 |
+
const [input, setInput] = useState('');
|
| 36 |
+
const [role, setRole] = useState<RoleFilter>('all');
|
| 37 |
+
const [sort, setSort] = useState<SortOrder>('score');
|
| 38 |
+
const [exact, setExact] = useState(false);
|
| 39 |
+
const [result, setResult] = useState<ExecutedSearch | null>(null);
|
| 40 |
+
const [searching, setSearching] = useState(false);
|
| 41 |
+
const [loadingMore, setLoadingMore] = useState(false);
|
| 42 |
+
const [error, setError] = useState<unknown>(null);
|
| 43 |
+
|
| 44 |
+
const runSearch = async () => {
|
| 45 |
+
const query = input.trim();
|
| 46 |
+
if (query === '' || searching) return;
|
| 47 |
+
setSearching(true);
|
| 48 |
+
setError(null);
|
| 49 |
+
try {
|
| 50 |
+
const token = config.token.trim();
|
| 51 |
+
const mode: SearchMode = exact ? 'literal' : 'terms';
|
| 52 |
+
const page = await fetchSearchPage({
|
| 53 |
+
baseUrl,
|
| 54 |
+
token: token === '' ? undefined : token,
|
| 55 |
+
query,
|
| 56 |
+
role: role === 'all' ? undefined : role,
|
| 57 |
+
sort,
|
| 58 |
+
mode,
|
| 59 |
+
pageSize: PAGE_SIZE,
|
| 60 |
+
});
|
| 61 |
+
setResult({
|
| 62 |
+
query,
|
| 63 |
+
role: role === 'all' ? undefined : role,
|
| 64 |
+
sort,
|
| 65 |
+
mode,
|
| 66 |
+
items: page.items,
|
| 67 |
+
hasMore: page.hasMore,
|
| 68 |
+
pageToken: page.pageToken,
|
| 69 |
+
incomplete: page.incomplete,
|
| 70 |
+
source: page.source,
|
| 71 |
+
indexState: page.indexState,
|
| 72 |
+
});
|
| 73 |
+
} catch (error) {
|
| 74 |
+
setError(error);
|
| 75 |
+
} finally {
|
| 76 |
+
setSearching(false);
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const loadMore = async () => {
|
| 81 |
+
if (result === null || !result.hasMore || loadingMore) return;
|
| 82 |
+
setLoadingMore(true);
|
| 83 |
+
setError(null);
|
| 84 |
+
try {
|
| 85 |
+
const token = config.token.trim();
|
| 86 |
+
const page = await fetchSearchPage({
|
| 87 |
+
baseUrl,
|
| 88 |
+
token: token === '' ? undefined : token,
|
| 89 |
+
query: result.query,
|
| 90 |
+
role: result.role,
|
| 91 |
+
sort: result.sort,
|
| 92 |
+
mode: result.mode,
|
| 93 |
+
pageSize: PAGE_SIZE,
|
| 94 |
+
pageToken: result.pageToken,
|
| 95 |
+
});
|
| 96 |
+
setResult({
|
| 97 |
+
...result,
|
| 98 |
+
items: [...result.items, ...page.items],
|
| 99 |
+
hasMore: page.hasMore,
|
| 100 |
+
pageToken: page.pageToken,
|
| 101 |
+
incomplete: page.incomplete,
|
| 102 |
+
source: page.source,
|
| 103 |
+
indexState: page.indexState,
|
| 104 |
+
});
|
| 105 |
+
} catch (error) {
|
| 106 |
+
setError(error);
|
| 107 |
+
} finally {
|
| 108 |
+
setLoadingMore(false);
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div className="flex min-w-0 flex-1 flex-col">
|
| 114 |
+
<div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
|
| 115 |
+
<input
|
| 116 |
+
className="w-96 rounded border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-[13px] text-neutral-100 outline-none focus:border-sky-600"
|
| 117 |
+
placeholder="Search messages across all sessions…"
|
| 118 |
+
value={input}
|
| 119 |
+
onChange={(e) => setInput(e.target.value)}
|
| 120 |
+
onKeyDown={(e) => {
|
| 121 |
+
if (e.key === 'Enter') {
|
| 122 |
+
e.preventDefault();
|
| 123 |
+
void runSearch();
|
| 124 |
+
}
|
| 125 |
+
}}
|
| 126 |
+
/>
|
| 127 |
+
<select
|
| 128 |
+
className="rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-300 outline-none"
|
| 129 |
+
value={role}
|
| 130 |
+
onChange={(e) => setRole(e.target.value as RoleFilter)}
|
| 131 |
+
title="Document role"
|
| 132 |
+
>
|
| 133 |
+
<option value="all">all roles</option>
|
| 134 |
+
<option value="user">user</option>
|
| 135 |
+
<option value="assistant">assistant</option>
|
| 136 |
+
<option value="title">title</option>
|
| 137 |
+
</select>
|
| 138 |
+
<select
|
| 139 |
+
className="rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-300 outline-none disabled:opacity-40"
|
| 140 |
+
value={sort}
|
| 141 |
+
onChange={(e) => setSort(e.target.value as SortOrder)}
|
| 142 |
+
title={exact ? 'Exact match always sorts by newest first' : 'Sort order'}
|
| 143 |
+
disabled={exact}
|
| 144 |
+
>
|
| 145 |
+
<option value="score">relevance</option>
|
| 146 |
+
<option value="time_desc">newest first</option>
|
| 147 |
+
<option value="time_asc">oldest first</option>
|
| 148 |
+
</select>
|
| 149 |
+
<label
|
| 150 |
+
className="flex cursor-pointer items-center gap-1.5 rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-300 select-none"
|
| 151 |
+
title="Substring match, case-insensitive (slower, matches symbols like C++)"
|
| 152 |
+
>
|
| 153 |
+
<input
|
| 154 |
+
type="checkbox"
|
| 155 |
+
className="accent-sky-600"
|
| 156 |
+
checked={exact}
|
| 157 |
+
onChange={(e) => setExact(e.target.checked)}
|
| 158 |
+
/>
|
| 159 |
+
exact match
|
| 160 |
+
</label>
|
| 161 |
+
<ActionButton onClick={() => void runSearch()} disabled={searching || input.trim() === ''}>
|
| 162 |
+
{searching ? 'Searching…' : 'Search'}
|
| 163 |
+
</ActionButton>
|
| 164 |
+
{result !== null ? (
|
| 165 |
+
<span className="ml-2 text-[11px] text-neutral-600">
|
| 166 |
+
{result.indexState.state === 'building'
|
| 167 |
+
? `index building (${result.indexState.indexedSessions}/${result.indexState.totalSessions} sessions) — results may be incomplete`
|
| 168 |
+
: `${result.indexState.documents} documents indexed (${result.indexState.state})`}
|
| 169 |
+
</span>
|
| 170 |
+
) : null}
|
| 171 |
+
{result?.source !== undefined ? (
|
| 172 |
+
<span
|
| 173 |
+
className="ml-2"
|
| 174 |
+
title={
|
| 175 |
+
result.source === 'live'
|
| 176 |
+
? 'searched the in-memory session transcript'
|
| 177 |
+
: 'searched the persisted search index'
|
| 178 |
+
}
|
| 179 |
+
>
|
| 180 |
+
<Badge tone={result.source === 'live' ? 'green' : 'neutral'}>
|
| 181 |
+
source: {result.source}
|
| 182 |
+
</Badge>
|
| 183 |
+
</span>
|
| 184 |
+
) : null}
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<div className="flex-1 overflow-y-auto px-4 py-3">
|
| 188 |
+
{error !== null ? (
|
| 189 |
+
<div className="mb-3">
|
| 190 |
+
<ErrorLine error={error} />
|
| 191 |
+
</div>
|
| 192 |
+
) : null}
|
| 193 |
+
{result === null ? (
|
| 194 |
+
<div className="text-[12px] text-neutral-600 italic">
|
| 195 |
+
Search user prompts, assistant replies and session titles across every session.
|
| 196 |
+
</div>
|
| 197 |
+
) : result.items.length === 0 && !searching ? (
|
| 198 |
+
<div className="text-[12px] text-neutral-600 italic">No hits for “{result.query}”.</div>
|
| 199 |
+
) : (
|
| 200 |
+
<div className="flex flex-col gap-2">
|
| 201 |
+
{result.incomplete === 'candidate_cap' ? (
|
| 202 |
+
<div className="text-[11px] text-neutral-600">
|
| 203 |
+
too many matches — the candidate set was truncated, results may be incomplete
|
| 204 |
+
</div>
|
| 205 |
+
) : null}
|
| 206 |
+
{result.items.map((hit, i) => (
|
| 207 |
+
<HitCard
|
| 208 |
+
key={`${hit.sessionId}/${hit.agentId}/${hit.time}/${i}`}
|
| 209 |
+
hit={hit}
|
| 210 |
+
onOpen={onOpenResult}
|
| 211 |
+
/>
|
| 212 |
+
))}
|
| 213 |
+
{result.hasMore ? (
|
| 214 |
+
<div className="mt-1 flex justify-center">
|
| 215 |
+
<ActionButton onClick={() => void loadMore()} disabled={loadingMore}>
|
| 216 |
+
{loadingMore ? 'Loading…' : 'Load more'}
|
| 217 |
+
</ActionButton>
|
| 218 |
+
</div>
|
| 219 |
+
) : null}
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function HitCard({ hit, onOpen }: { hit: SearchHit; onOpen: (hit: SearchHit) => void }) {
|
| 228 |
+
return (
|
| 229 |
+
<button
|
| 230 |
+
type="button"
|
| 231 |
+
className="w-full rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2 text-left transition-colors hover:border-sky-800 hover:bg-neutral-900"
|
| 232 |
+
onClick={() => onOpen(hit)}
|
| 233 |
+
title="Open in chat"
|
| 234 |
+
>
|
| 235 |
+
<div className="mb-1 flex flex-wrap items-center gap-2">
|
| 236 |
+
<span className="text-[12px] font-medium text-neutral-200">
|
| 237 |
+
{hit.sessionTitle !== '' ? hit.sessionTitle : '(untitled session)'}
|
| 238 |
+
</span>
|
| 239 |
+
<Badge tone={hit.role === 'user' ? 'sky' : hit.role === 'assistant' ? 'green' : 'violet'}>
|
| 240 |
+
{hit.role}
|
| 241 |
+
</Badge>
|
| 242 |
+
{hit.agentId !== '' ? <Badge tone="neutral">agent: {hit.agentId}</Badge> : null}
|
| 243 |
+
<span className="ml-auto text-[10px] text-neutral-600">{relTime(hit.time)}</span>
|
| 244 |
+
</div>
|
| 245 |
+
<div className="mb-1 whitespace-pre-wrap text-[12px] text-neutral-300">{hit.snippet}</div>
|
| 246 |
+
<div className="flex flex-wrap items-center gap-2 font-mono text-[10px] text-neutral-600">
|
| 247 |
+
<span>{hit.sessionId}</span>
|
| 248 |
+
{hit.turn !== undefined ? <span>t{hit.turn}</span> : null}
|
| 249 |
+
{hit.stepId !== undefined ? <span>{hit.stepId}</span> : null}
|
| 250 |
+
<span>score {hit.score.toFixed(2)}</span>
|
| 251 |
+
</div>
|
| 252 |
+
</button>
|
| 253 |
+
);
|
| 254 |
+
}
|
apps/kimi-inspect/src/components/ServerSwitcher.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Header server switcher — lists the locally discovered kap-servers (dev
|
| 3 |
+
* middleware `/__inspect/servers`) and switches the connection between them
|
| 4 |
+
* without a reload. Discovered picks are not persisted as a full connection
|
| 5 |
+
* config; only the picked URL is remembered so a reload re-picks it while the
|
| 6 |
+
* instance is still alive. Falls back to a plain read-only URL when discovery
|
| 7 |
+
* is unavailable (static hosting) or finds no local servers.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { useConnection } from '../connection';
|
| 11 |
+
import { useServerDiscovery } from '../servers';
|
| 12 |
+
|
| 13 |
+
export function ServerSwitcher() {
|
| 14 |
+
const { baseUrl, connect } = useConnection();
|
| 15 |
+
const discovery = useServerDiscovery();
|
| 16 |
+
const data = discovery.data;
|
| 17 |
+
if (data === null || data === undefined || data.servers.length === 0) {
|
| 18 |
+
// No local discovery (static hosting / no servers): plain read-only URL.
|
| 19 |
+
return <span className="font-mono text-[10px] text-neutral-500">{baseUrl}</span>;
|
| 20 |
+
}
|
| 21 |
+
const current = data.servers.find((s) => s.url === baseUrl);
|
| 22 |
+
return (
|
| 23 |
+
<select
|
| 24 |
+
className="rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300 outline-none focus:border-sky-600"
|
| 25 |
+
title={`Local kap-servers (${data.home})`}
|
| 26 |
+
value={current?.id ?? '__custom'}
|
| 27 |
+
onChange={(e) => {
|
| 28 |
+
const target = data.servers.find((s) => s.id === e.target.value);
|
| 29 |
+
if (target === undefined || target.url === baseUrl) return;
|
| 30 |
+
connect(
|
| 31 |
+
{ url: target.url, token: data.token ?? '' },
|
| 32 |
+
{ persist: false, rememberServerUrl: target.url },
|
| 33 |
+
);
|
| 34 |
+
}}
|
| 35 |
+
>
|
| 36 |
+
{current === undefined ? (
|
| 37 |
+
<option value="__custom">custom: {baseUrl.replace(/^https?:\/\//, '')}</option>
|
| 38 |
+
) : null}
|
| 39 |
+
{data.servers.map((s) => (
|
| 40 |
+
<option key={s.id} value={s.id}>
|
| 41 |
+
{s.url.replace(/^https?:\/\//, '')}
|
| 42 |
+
{s.pid !== undefined ? ` · pid ${s.pid}` : ''}
|
| 43 |
+
{s.source === 'proxy' ? ' · proxy' : ''}
|
| 44 |
+
</option>
|
| 45 |
+
))}
|
| 46 |
+
</select>
|
| 47 |
+
);
|
| 48 |
+
}
|
apps/kimi-inspect/src/components/ServicePanels.tsx
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Scope panels — the merged Service list for one wire scope (`app` /
|
| 3 |
+
* `session` / `agent`), shared by the chat inspector (session + agent tabs)
|
| 4 |
+
* and the standalone App Services rail view. The baseline is the dynamic
|
| 5 |
+
* channel list served by `GET /api/v1/debug/channels` (every wire-exposed
|
| 6 |
+
* Service with its methods), rendered by `DynamicServiceCard`; the
|
| 7 |
+
* handwritten descriptors in `panels.ts` override individual Services with
|
| 8 |
+
* curated cards (`ServiceCard`).
|
| 9 |
+
*
|
| 10 |
+
* Two layouts consume the same merged list (`useScopePanels`):
|
| 11 |
+
* - `ScopePanels`: a plain stacked card list (the chat inspector).
|
| 12 |
+
* - `ScopePanelsScrollspy`: a Postman-style three-pane layout — Service
|
| 13 |
+
* list on the left with a fuzzy filter box, every Service's card
|
| 14 |
+
* expanded (no collapsing) in one continuously scrolling column in the
|
| 15 |
+
* middle (scroll position drives the left-side highlight, clicks scroll
|
| 16 |
+
* the middle), and a request/response call history on the right fed by
|
| 17 |
+
* recording proxies around every card. Card headers copy the Service
|
| 18 |
+
* name on click (`CopyableName`).
|
| 19 |
+
*
|
| 20 |
+
* Everything here is fetch-on-demand (Load / Refresh buttons): the v2 event
|
| 21 |
+
* socket (`/api/v2/ws`) that used to push core/session/agent event streams
|
| 22 |
+
* was removed server-side, so there is no live push to render.
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
import { useQuery } from '@tanstack/react-query';
|
| 26 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 27 |
+
|
| 28 |
+
import { fetchChannelDescriptors, type ChannelDescriptor, type ChannelScope } from '../channel';
|
| 29 |
+
import { useConnection } from '../connection';
|
| 30 |
+
import {
|
| 31 |
+
AGENT_PANELS,
|
| 32 |
+
CORE_PANELS,
|
| 33 |
+
SESSION_PANELS,
|
| 34 |
+
call,
|
| 35 |
+
type AnyService,
|
| 36 |
+
type ServicePanelDef,
|
| 37 |
+
} from '../panels';
|
| 38 |
+
import { ActionButton, Badge, ErrorLine, JsonView, errorMessage, relTime } from '../ui';
|
| 39 |
+
import { buildArgs, fieldKey, parseParamFields, type ParamField } from './methodArgs';
|
| 40 |
+
|
| 41 |
+
const PANEL_OVERRIDES: ReadonlyMap<string, ServicePanelDef> = new Map(
|
| 42 |
+
[...CORE_PANELS, ...SESSION_PANELS, ...AGENT_PANELS].map((def) => [def.id, def]),
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
/** One merged Service entry: the wire channel plus its curated override, if any. */
|
| 46 |
+
interface ScopeEntry {
|
| 47 |
+
readonly name: string;
|
| 48 |
+
readonly channel?: ChannelDescriptor;
|
| 49 |
+
readonly def?: ServicePanelDef;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/** Load the full protocol list once per connection (every channel, 1:1). */
|
| 53 |
+
function useChannels() {
|
| 54 |
+
const { klient } = useConnection();
|
| 55 |
+
return useQuery({
|
| 56 |
+
queryKey: ['channels', klient.baseUrl],
|
| 57 |
+
queryFn: () => fetchChannelDescriptors(klient),
|
| 58 |
+
staleTime: Number.POSITIVE_INFINITY,
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Merge the dynamic channel list with the handwritten overrides for one
|
| 64 |
+
* scope. When the channels endpoint is unavailable, fall back to the
|
| 65 |
+
* handwritten panels only.
|
| 66 |
+
*/
|
| 67 |
+
function useScopePanels(scope: ChannelScope) {
|
| 68 |
+
const channels = useChannels();
|
| 69 |
+
const entries = useMemo<readonly ScopeEntry[]>(() => {
|
| 70 |
+
const byName = new Map<string, ChannelDescriptor | undefined>();
|
| 71 |
+
if (channels.data !== undefined) {
|
| 72 |
+
for (const c of channels.data) {
|
| 73 |
+
if (c.scope === scope) byName.set(c.name, c);
|
| 74 |
+
}
|
| 75 |
+
// Keep overrides the introspection missed (e.g. server drift).
|
| 76 |
+
for (const def of PANEL_OVERRIDES.values()) {
|
| 77 |
+
if (def.scope === scope && !byName.has(def.id)) byName.set(def.id, undefined);
|
| 78 |
+
}
|
| 79 |
+
} else {
|
| 80 |
+
for (const def of PANEL_OVERRIDES.values()) {
|
| 81 |
+
if (def.scope === scope) byName.set(def.id, undefined);
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
return [...byName.entries()].map(([name, channel]) => ({
|
| 85 |
+
name,
|
| 86 |
+
channel,
|
| 87 |
+
def: PANEL_OVERRIDES.get(name),
|
| 88 |
+
}));
|
| 89 |
+
}, [channels.data, scope]);
|
| 90 |
+
return { entries, channels };
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* Render every Service of one scope as a plain stacked card list.
|
| 95 |
+
* `proxyFor` materializes the Service proxy for a channel name (null when
|
| 96 |
+
* the scope is not callable, e.g. no session selected); `onError` observes
|
| 97 |
+
* call failures (the agent switcher uses it to mark unloaded agents).
|
| 98 |
+
*/
|
| 99 |
+
export function ScopePanels({
|
| 100 |
+
scope,
|
| 101 |
+
proxyFor,
|
| 102 |
+
onError,
|
| 103 |
+
}: {
|
| 104 |
+
readonly scope: ChannelScope;
|
| 105 |
+
readonly proxyFor: (name: string) => AnyService | null;
|
| 106 |
+
readonly onError?: (error: unknown) => void;
|
| 107 |
+
}) {
|
| 108 |
+
const { entries, channels } = useScopePanels(scope);
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<>
|
| 112 |
+
{channels.isError ? (
|
| 113 |
+
<div className="mb-2">
|
| 114 |
+
<ErrorLine error={channels.error} />
|
| 115 |
+
<div className="mt-1 text-[10px] text-neutral-600">
|
| 116 |
+
dynamic channel list unavailable — showing handwritten panels only
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
) : null}
|
| 120 |
+
{entries.map(({ name, channel, def }) => {
|
| 121 |
+
if (def !== undefined) {
|
| 122 |
+
return <ServiceCard key={name} def={def} svc={proxyFor(name)} onError={onError} />;
|
| 123 |
+
}
|
| 124 |
+
if (channel === undefined) return null;
|
| 125 |
+
return (
|
| 126 |
+
<DynamicServiceCard key={name} channel={channel} svc={proxyFor(name)} onError={onError} />
|
| 127 |
+
);
|
| 128 |
+
})}
|
| 129 |
+
</>
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* Subsequence fuzzy match (case-insensitive): `ssm` matches
|
| 135 |
+
* `sessionScopeMetadata`. An empty query matches everything.
|
| 136 |
+
*/
|
| 137 |
+
function fuzzyMatch(name: string, query: string): boolean {
|
| 138 |
+
const q = query.trim().toLowerCase();
|
| 139 |
+
if (q === '') return true;
|
| 140 |
+
const n = name.toLowerCase();
|
| 141 |
+
let i = 0;
|
| 142 |
+
for (const ch of n) {
|
| 143 |
+
if (ch === q[i]) i++;
|
| 144 |
+
if (i === q.length) return true;
|
| 145 |
+
}
|
| 146 |
+
return false;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* Three-pane scope browser: the Service list on the left (with a fuzzy
|
| 151 |
+
* filter box), every Service's card expanded in one continuously scrolling
|
| 152 |
+
* column in the middle, and a Postman-style call history on the right
|
| 153 |
+
* (`HistoryPane`) recording every wire request/response the cards make.
|
| 154 |
+
* The scroll position highlights the matching list item (and keeps it
|
| 155 |
+
* visible); clicking a list item smooth-scrolls the middle column to that
|
| 156 |
+
* Service. The list hides below the `md` breakpoint and the history below
|
| 157 |
+
* `lg`.
|
| 158 |
+
*/
|
| 159 |
+
export function ScopePanelsScrollspy({
|
| 160 |
+
scope,
|
| 161 |
+
title,
|
| 162 |
+
proxyFor,
|
| 163 |
+
onError,
|
| 164 |
+
}: {
|
| 165 |
+
readonly scope: ChannelScope;
|
| 166 |
+
readonly title: string;
|
| 167 |
+
readonly proxyFor: (name: string) => AnyService | null;
|
| 168 |
+
readonly onError?: (error: unknown) => void;
|
| 169 |
+
}) {
|
| 170 |
+
const { entries, channels } = useScopePanels(scope);
|
| 171 |
+
const [active, setActive] = useState<string | null>(null);
|
| 172 |
+
const [query, setQuery] = useState('');
|
| 173 |
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
| 174 |
+
const sectionRefs = useRef(new Map<string, HTMLElement>());
|
| 175 |
+
const navRefs = useRef(new Map<string, HTMLButtonElement>());
|
| 176 |
+
|
| 177 |
+
// The filter narrows the left list only; the middle column keeps every
|
| 178 |
+
// card mounted so in-flight inputs and results survive a search.
|
| 179 |
+
const filtered = useMemo(
|
| 180 |
+
() => entries.filter(({ name }) => fuzzyMatch(name, query)),
|
| 181 |
+
[entries, query],
|
| 182 |
+
);
|
| 183 |
+
|
| 184 |
+
// Call history: every card proxy is wrapped (`makeRecordingProxy`), so
|
| 185 |
+
// curated and dynamic cards record uniformly without per-card plumbing.
|
| 186 |
+
const [records, setRecords] = useState<readonly CallRecord[]>([]);
|
| 187 |
+
const [expandedId, setExpandedId] = useState<number | null>(null);
|
| 188 |
+
const recordId = useRef(0);
|
| 189 |
+
const record = useCallback((entry: Omit<CallRecord, 'id'>) => {
|
| 190 |
+
const id = ++recordId.current;
|
| 191 |
+
setRecords((prev) => [{ ...entry, id }, ...prev].slice(0, 100));
|
| 192 |
+
// Like Postman's response pane: the newest call is always open.
|
| 193 |
+
setExpandedId(id);
|
| 194 |
+
}, []);
|
| 195 |
+
const recordingProxyFor = useCallback(
|
| 196 |
+
(name: string): AnyService | null => {
|
| 197 |
+
const svc = proxyFor(name);
|
| 198 |
+
return svc === null ? null : makeRecordingProxy(name, svc, record);
|
| 199 |
+
},
|
| 200 |
+
[proxyFor, record],
|
| 201 |
+
);
|
| 202 |
+
|
| 203 |
+
// Highlight the section nearest the top of the scroll port. Driven by the
|
| 204 |
+
// scroll event (not IntersectionObserver) so tall sections and the tail of
|
| 205 |
+
// the list behave predictably. Section refs sit in DOM order in the map.
|
| 206 |
+
const syncActive = useCallback(() => {
|
| 207 |
+
const root = scrollRef.current;
|
| 208 |
+
if (root === null) return;
|
| 209 |
+
const rootTop = root.getBoundingClientRect().top;
|
| 210 |
+
let current: string | null = null;
|
| 211 |
+
for (const [name, el] of sectionRefs.current) {
|
| 212 |
+
if (el.getBoundingClientRect().top - rootTop <= 96) current = name;
|
| 213 |
+
else break;
|
| 214 |
+
}
|
| 215 |
+
current ??= entries[0]?.name ?? null;
|
| 216 |
+
setActive((prev) => (prev === current ? prev : current));
|
| 217 |
+
}, [entries]);
|
| 218 |
+
|
| 219 |
+
// Sections mount as the channel list resolves, so re-sync on list changes.
|
| 220 |
+
useEffect(() => {
|
| 221 |
+
syncActive();
|
| 222 |
+
}, [syncActive]);
|
| 223 |
+
|
| 224 |
+
// Keep the highlighted item inside the left list's viewport.
|
| 225 |
+
useEffect(() => {
|
| 226 |
+
if (active === null) return;
|
| 227 |
+
navRefs.current.get(active)?.scrollIntoView({ block: 'nearest' });
|
| 228 |
+
}, [active]);
|
| 229 |
+
|
| 230 |
+
const scrollTo = (name: string) => {
|
| 231 |
+
setActive(name);
|
| 232 |
+
sectionRefs.current.get(name)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<div className="flex min-h-0 min-w-0 flex-1">
|
| 237 |
+
<nav className="hidden w-56 shrink-0 overflow-y-auto border-r border-neutral-800 py-2 md:block">
|
| 238 |
+
<div className="px-3 pt-1 pb-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 239 |
+
{title}
|
| 240 |
+
</div>
|
| 241 |
+
<div className="px-2 pb-2">
|
| 242 |
+
<input
|
| 243 |
+
className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
| 244 |
+
placeholder="Filter services…"
|
| 245 |
+
value={query}
|
| 246 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 247 |
+
/>
|
| 248 |
+
</div>
|
| 249 |
+
{filtered.length === 0 ? (
|
| 250 |
+
<div className="px-3 py-1 text-[11px] text-neutral-600 italic">no matches</div>
|
| 251 |
+
) : null}
|
| 252 |
+
{filtered.map(({ name, def }) => (
|
| 253 |
+
<button
|
| 254 |
+
key={name}
|
| 255 |
+
type="button"
|
| 256 |
+
ref={(el) => {
|
| 257 |
+
if (el === null) navRefs.current.delete(name);
|
| 258 |
+
else navRefs.current.set(name, el);
|
| 259 |
+
}}
|
| 260 |
+
onClick={() => scrollTo(name)}
|
| 261 |
+
className={`block w-full truncate px-3 py-1 text-left font-mono text-[11px] transition-colors ${
|
| 262 |
+
active === name
|
| 263 |
+
? 'bg-neutral-800 text-sky-400'
|
| 264 |
+
: 'text-neutral-500 hover:bg-neutral-800/60 hover:text-neutral-300'
|
| 265 |
+
}`}
|
| 266 |
+
title={def?.label ?? name}
|
| 267 |
+
>
|
| 268 |
+
{name}
|
| 269 |
+
</button>
|
| 270 |
+
))}
|
| 271 |
+
</nav>
|
| 272 |
+
<div
|
| 273 |
+
ref={scrollRef}
|
| 274 |
+
onScroll={syncActive}
|
| 275 |
+
className="min-w-0 flex-1 overflow-y-auto px-4 py-3"
|
| 276 |
+
>
|
| 277 |
+
{channels.isError ? (
|
| 278 |
+
<div className="mb-2">
|
| 279 |
+
<ErrorLine error={channels.error} />
|
| 280 |
+
<div className="mt-1 text-[10px] text-neutral-600">
|
| 281 |
+
dynamic channel list unavailable — showing handwritten panels only
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
) : null}
|
| 285 |
+
{entries.map(({ name, channel, def }) => (
|
| 286 |
+
<section
|
| 287 |
+
key={name}
|
| 288 |
+
ref={(el) => {
|
| 289 |
+
if (el === null) sectionRefs.current.delete(name);
|
| 290 |
+
else sectionRefs.current.set(name, el);
|
| 291 |
+
}}
|
| 292 |
+
className="scroll-mt-3"
|
| 293 |
+
>
|
| 294 |
+
{def !== undefined ? (
|
| 295 |
+
<ServiceCard def={def} svc={recordingProxyFor(name)} onError={onError} />
|
| 296 |
+
) : channel !== undefined ? (
|
| 297 |
+
<DynamicServiceCard
|
| 298 |
+
channel={channel}
|
| 299 |
+
svc={recordingProxyFor(name)}
|
| 300 |
+
onError={onError}
|
| 301 |
+
collapsible={false}
|
| 302 |
+
inlineResults={false}
|
| 303 |
+
/>
|
| 304 |
+
) : null}
|
| 305 |
+
</section>
|
| 306 |
+
))}
|
| 307 |
+
</div>
|
| 308 |
+
<HistoryPane
|
| 309 |
+
records={records}
|
| 310 |
+
expandedId={expandedId}
|
| 311 |
+
onToggle={(id) => setExpandedId((prev) => (prev === id ? null : id))}
|
| 312 |
+
onClear={() => {
|
| 313 |
+
setRecords([]);
|
| 314 |
+
setExpandedId(null);
|
| 315 |
+
}}
|
| 316 |
+
/>
|
| 317 |
+
</div>
|
| 318 |
+
);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
// ---------------------------------------------------------------------------
|
| 322 |
+
// Call history — one record per wire call, newest first, capped at 100.
|
| 323 |
+
// ---------------------------------------------------------------------------
|
| 324 |
+
|
| 325 |
+
interface CallRecord {
|
| 326 |
+
readonly id: number;
|
| 327 |
+
readonly service: string;
|
| 328 |
+
readonly method: string;
|
| 329 |
+
readonly args: readonly unknown[];
|
| 330 |
+
readonly at: number;
|
| 331 |
+
readonly durationMs: number;
|
| 332 |
+
readonly ok: boolean;
|
| 333 |
+
readonly result?: unknown;
|
| 334 |
+
readonly error?: string;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/**
|
| 338 |
+
* Wrap a Service proxy so every method call is recorded (args, result or
|
| 339 |
+
* error, duration) before its promise settles. Errors rethrow unchanged, so
|
| 340 |
+
* the calling card's own error handling still runs.
|
| 341 |
+
*/
|
| 342 |
+
function makeRecordingProxy(
|
| 343 |
+
service: string,
|
| 344 |
+
svc: AnyService,
|
| 345 |
+
record: (entry: Omit<CallRecord, 'id'>) => void,
|
| 346 |
+
): AnyService {
|
| 347 |
+
return new Proxy(svc, {
|
| 348 |
+
get(target, prop) {
|
| 349 |
+
const member = target[prop as string];
|
| 350 |
+
if (typeof member !== 'function') return member;
|
| 351 |
+
return (...args: unknown[]) => {
|
| 352 |
+
const at = Date.now();
|
| 353 |
+
return Promise.resolve(member(...args)).then(
|
| 354 |
+
(result) => {
|
| 355 |
+
record({
|
| 356 |
+
service,
|
| 357 |
+
method: String(prop),
|
| 358 |
+
args,
|
| 359 |
+
at,
|
| 360 |
+
durationMs: Date.now() - at,
|
| 361 |
+
ok: true,
|
| 362 |
+
result,
|
| 363 |
+
});
|
| 364 |
+
return result;
|
| 365 |
+
},
|
| 366 |
+
(error: unknown) => {
|
| 367 |
+
record({
|
| 368 |
+
service,
|
| 369 |
+
method: String(prop),
|
| 370 |
+
args,
|
| 371 |
+
at,
|
| 372 |
+
durationMs: Date.now() - at,
|
| 373 |
+
ok: false,
|
| 374 |
+
error: errorMessage(error),
|
| 375 |
+
});
|
| 376 |
+
throw error;
|
| 377 |
+
},
|
| 378 |
+
);
|
| 379 |
+
};
|
| 380 |
+
},
|
| 381 |
+
});
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
function HistoryPane({
|
| 385 |
+
records,
|
| 386 |
+
expandedId,
|
| 387 |
+
onToggle,
|
| 388 |
+
onClear,
|
| 389 |
+
}: {
|
| 390 |
+
readonly records: readonly CallRecord[];
|
| 391 |
+
readonly expandedId: number | null;
|
| 392 |
+
readonly onToggle: (id: number) => void;
|
| 393 |
+
readonly onClear: () => void;
|
| 394 |
+
}) {
|
| 395 |
+
return (
|
| 396 |
+
<div className="hidden w-[360px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30 lg:flex">
|
| 397 |
+
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
| 398 |
+
<span className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 399 |
+
History {records.length > 0 ? `(${records.length})` : ''}
|
| 400 |
+
</span>
|
| 401 |
+
{records.length > 0 ? <ActionButton onClick={onClear}>Clear</ActionButton> : null}
|
| 402 |
+
</div>
|
| 403 |
+
<div className="flex-1 overflow-y-auto p-2">
|
| 404 |
+
{records.length === 0 ? (
|
| 405 |
+
<div className="px-1 text-[11px] text-neutral-600 italic">
|
| 406 |
+
call any method to see the request/response here
|
| 407 |
+
</div>
|
| 408 |
+
) : null}
|
| 409 |
+
{records.map((r) => {
|
| 410 |
+
const open = r.id === expandedId;
|
| 411 |
+
return (
|
| 412 |
+
<div key={r.id} className="mb-2 rounded border border-neutral-800 bg-neutral-900/60">
|
| 413 |
+
<div
|
| 414 |
+
className="flex cursor-pointer items-center gap-2 px-2 py-1.5 select-none"
|
| 415 |
+
onClick={() => onToggle(r.id)}
|
| 416 |
+
>
|
| 417 |
+
<span
|
| 418 |
+
className={`shrink-0 text-[9px] ${r.ok ? 'text-emerald-400' : 'text-red-400'}`}
|
| 419 |
+
>
|
| 420 |
+
●
|
| 421 |
+
</span>
|
| 422 |
+
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-neutral-200">
|
| 423 |
+
{r.service}.{r.method}
|
| 424 |
+
</span>
|
| 425 |
+
<span className="shrink-0 text-[10px] text-neutral-600">{r.durationMs}ms</span>
|
| 426 |
+
<span className="shrink-0 text-[10px] text-neutral-600">{relTime(r.at)}</span>
|
| 427 |
+
<span className="shrink-0 text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
|
| 428 |
+
</div>
|
| 429 |
+
{open ? (
|
| 430 |
+
<div className="border-t border-neutral-800/60 px-2 py-1.5">
|
| 431 |
+
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
|
| 432 |
+
Request
|
| 433 |
+
</div>
|
| 434 |
+
<JsonView data={r.args} empty="(no args)" />
|
| 435 |
+
<div className="mt-1.5 mb-0.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
|
| 436 |
+
{r.ok ? 'Response' : 'Error'}
|
| 437 |
+
</div>
|
| 438 |
+
{r.ok ? (
|
| 439 |
+
<JsonView data={r.result} empty="(no result)" />
|
| 440 |
+
) : (
|
| 441 |
+
<div className="text-[11px] break-words text-red-400">{r.error}</div>
|
| 442 |
+
)}
|
| 443 |
+
</div>
|
| 444 |
+
) : null}
|
| 445 |
+
</div>
|
| 446 |
+
);
|
| 447 |
+
})}
|
| 448 |
+
</div>
|
| 449 |
+
</div>
|
| 450 |
+
);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
// ---------------------------------------------------------------------------
|
| 454 |
+
// Generic service card
|
| 455 |
+
// ---------------------------------------------------------------------------
|
| 456 |
+
|
| 457 |
+
/** Service name that copies itself to the clipboard on click. */
|
| 458 |
+
function CopyableName({ name, className }: { name: string; className?: string }) {
|
| 459 |
+
const [copied, setCopied] = useState(false);
|
| 460 |
+
return (
|
| 461 |
+
<button
|
| 462 |
+
type="button"
|
| 463 |
+
title={`click to copy: ${name}`}
|
| 464 |
+
className={className}
|
| 465 |
+
onClick={(e) => {
|
| 466 |
+
// Don't trigger the collapsible header toggle behind the name.
|
| 467 |
+
e.stopPropagation();
|
| 468 |
+
void navigator.clipboard.writeText(name).then(() => {
|
| 469 |
+
setCopied(true);
|
| 470 |
+
setTimeout(() => setCopied(false), 1200);
|
| 471 |
+
});
|
| 472 |
+
}}
|
| 473 |
+
>
|
| 474 |
+
{copied ? 'copied ✓' : name}
|
| 475 |
+
</button>
|
| 476 |
+
);
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
function ServiceCard({
|
| 480 |
+
def,
|
| 481 |
+
svc,
|
| 482 |
+
onError,
|
| 483 |
+
}: {
|
| 484 |
+
def: ServicePanelDef;
|
| 485 |
+
svc: AnyService | null;
|
| 486 |
+
onError?: (error: unknown) => void;
|
| 487 |
+
}) {
|
| 488 |
+
const [data, setData] = useState<unknown>(undefined);
|
| 489 |
+
const [error, setError] = useState<unknown>(null);
|
| 490 |
+
const [busy, setBusy] = useState<string | null>(null);
|
| 491 |
+
const [loaded, setLoaded] = useState(false);
|
| 492 |
+
|
| 493 |
+
const refresh = async () => {
|
| 494 |
+
if (svc === null || def.fetch === undefined) return;
|
| 495 |
+
try {
|
| 496 |
+
setError(null);
|
| 497 |
+
const result = await def.fetch(svc);
|
| 498 |
+
setData(result);
|
| 499 |
+
setLoaded(true);
|
| 500 |
+
} catch (error) {
|
| 501 |
+
setError(error);
|
| 502 |
+
onError?.(error);
|
| 503 |
+
}
|
| 504 |
+
};
|
| 505 |
+
|
| 506 |
+
return (
|
| 507 |
+
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
|
| 508 |
+
<div className="flex items-center justify-between border-b border-neutral-800/60 px-3 py-2">
|
| 509 |
+
<div>
|
| 510 |
+
<span className="text-[12px] font-medium text-neutral-200">{def.label}</span>
|
| 511 |
+
<CopyableName
|
| 512 |
+
name={def.id}
|
| 513 |
+
className="ml-2 font-mono text-[10px] text-neutral-600 hover:text-sky-400"
|
| 514 |
+
/>
|
| 515 |
+
</div>
|
| 516 |
+
{def.fetch !== undefined ? (
|
| 517 |
+
<ActionButton onClick={() => void refresh()} disabled={svc === null}>
|
| 518 |
+
{loaded ? 'Refresh' : 'Load'}
|
| 519 |
+
</ActionButton>
|
| 520 |
+
) : null}
|
| 521 |
+
</div>
|
| 522 |
+
<div className="px-3 py-2">
|
| 523 |
+
{error !== null ? (
|
| 524 |
+
<div className="mb-2">
|
| 525 |
+
<ErrorLine error={error} />
|
| 526 |
+
</div>
|
| 527 |
+
) : null}
|
| 528 |
+
{def.fetch !== undefined ? (
|
| 529 |
+
loaded ? (
|
| 530 |
+
<JsonView data={data} />
|
| 531 |
+
) : (
|
| 532 |
+
<div className="text-[11px] text-neutral-600 italic">
|
| 533 |
+
click Load to read this Service
|
| 534 |
+
</div>
|
| 535 |
+
)
|
| 536 |
+
) : null}
|
| 537 |
+
{def.actions !== undefined && def.actions.length > 0 ? (
|
| 538 |
+
<div className="mt-2 flex flex-wrap gap-1.5">
|
| 539 |
+
{def.actions.map((action) => (
|
| 540 |
+
<ActionButton
|
| 541 |
+
key={action.label}
|
| 542 |
+
danger={action.danger}
|
| 543 |
+
disabled={svc === null || busy !== null}
|
| 544 |
+
onClick={async () => {
|
| 545 |
+
if (svc === null) return;
|
| 546 |
+
let input: string | undefined;
|
| 547 |
+
if (action.input !== undefined) {
|
| 548 |
+
const raw = window.prompt(action.input);
|
| 549 |
+
if (raw === null) return;
|
| 550 |
+
input = raw;
|
| 551 |
+
}
|
| 552 |
+
setBusy(action.label);
|
| 553 |
+
setError(null);
|
| 554 |
+
try {
|
| 555 |
+
const result = await action.run(svc, input);
|
| 556 |
+
if (result !== undefined && def.fetch === undefined) setData(result);
|
| 557 |
+
if (def.fetch !== undefined) await refresh();
|
| 558 |
+
} catch (error) {
|
| 559 |
+
setError(error);
|
| 560 |
+
onError?.(error);
|
| 561 |
+
} finally {
|
| 562 |
+
setBusy(null);
|
| 563 |
+
}
|
| 564 |
+
}}
|
| 565 |
+
>
|
| 566 |
+
{busy === action.label ? '…' : action.label}
|
| 567 |
+
</ActionButton>
|
| 568 |
+
))}
|
| 569 |
+
</div>
|
| 570 |
+
) : null}
|
| 571 |
+
{def.fetch === undefined && data !== undefined ? (
|
| 572 |
+
<div className="mt-2">
|
| 573 |
+
<JsonView data={data} />
|
| 574 |
+
</div>
|
| 575 |
+
) : null}
|
| 576 |
+
</div>
|
| 577 |
+
</div>
|
| 578 |
+
);
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
// ---------------------------------------------------------------------------
|
| 582 |
+
// Dynamic service card — generic renderer for channels without a handwritten
|
| 583 |
+
// override. Every method gets a call button labeled with its declared
|
| 584 |
+
// signature; parameters become structured inputs (`MethodArgInputs`: one
|
| 585 |
+
// field per parameter, one per key for destructured objects, smart-parsed —
|
| 586 |
+
// see `methodArgs.ts`). Getters become read buttons. Results render inline.
|
| 587 |
+
// ---------------------------------------------------------------------------
|
| 588 |
+
|
| 589 |
+
function DynamicServiceCard({
|
| 590 |
+
channel,
|
| 591 |
+
svc,
|
| 592 |
+
onError,
|
| 593 |
+
collapsible = true,
|
| 594 |
+
inlineResults = true,
|
| 595 |
+
}: {
|
| 596 |
+
channel: ChannelDescriptor;
|
| 597 |
+
svc: AnyService | null;
|
| 598 |
+
onError?: (error: unknown) => void;
|
| 599 |
+
/**
|
| 600 |
+
* Allow collapsing the method list behind the header. Off in the
|
| 601 |
+
* scrollspy layout, where every card stays expanded and the header name
|
| 602 |
+
* is a copy target instead of a toggle.
|
| 603 |
+
*/
|
| 604 |
+
collapsible?: boolean;
|
| 605 |
+
/**
|
| 606 |
+
* Render call results inline under each method. Off in the scrollspy
|
| 607 |
+
* layout, where results land in the right-side history pane instead.
|
| 608 |
+
*/
|
| 609 |
+
inlineResults?: boolean;
|
| 610 |
+
}) {
|
| 611 |
+
const [open, setOpen] = useState(false);
|
| 612 |
+
const [inputs, setInputs] = useState<Record<string, Record<string, string>>>({});
|
| 613 |
+
const [results, setResults] = useState<Record<string, unknown>>({});
|
| 614 |
+
const [errors, setErrors] = useState<Record<string, unknown>>({});
|
| 615 |
+
const [busy, setBusy] = useState<string | null>(null);
|
| 616 |
+
|
| 617 |
+
const invoke = async (method: ChannelDescriptor['methods'][number]) => {
|
| 618 |
+
if (svc === null) return;
|
| 619 |
+
let argv: unknown[];
|
| 620 |
+
try {
|
| 621 |
+
argv = buildArgs(parseParamFields(method.params), inputs[method.name] ?? {});
|
| 622 |
+
} catch {
|
| 623 |
+
setErrors((prev) => ({ ...prev, [method.name]: new Error('arg is not valid JSON') }));
|
| 624 |
+
return;
|
| 625 |
+
}
|
| 626 |
+
setBusy(method.name);
|
| 627 |
+
setErrors((prev) => ({ ...prev, [method.name]: null }));
|
| 628 |
+
try {
|
| 629 |
+
const result = await call(svc, method.name, ...argv);
|
| 630 |
+
setResults((prev) => ({ ...prev, [method.name]: result ?? '(no result)' }));
|
| 631 |
+
} catch (error) {
|
| 632 |
+
setErrors((prev) => ({ ...prev, [method.name]: error }));
|
| 633 |
+
onError?.(error);
|
| 634 |
+
} finally {
|
| 635 |
+
setBusy(null);
|
| 636 |
+
}
|
| 637 |
+
};
|
| 638 |
+
|
| 639 |
+
return (
|
| 640 |
+
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
|
| 641 |
+
<div
|
| 642 |
+
className={`flex items-center justify-between px-3 py-2 ${
|
| 643 |
+
collapsible ? 'cursor-pointer select-none' : ''
|
| 644 |
+
}`}
|
| 645 |
+
onClick={collapsible ? () => setOpen((v) => !v) : undefined}
|
| 646 |
+
>
|
| 647 |
+
<div>
|
| 648 |
+
<CopyableName
|
| 649 |
+
name={channel.name}
|
| 650 |
+
className="text-[12px] font-medium text-neutral-300 hover:text-sky-400"
|
| 651 |
+
/>
|
| 652 |
+
<span className="ml-2 text-[10px] text-neutral-600">
|
| 653 |
+
{channel.methods.length} methods · {channel.domain}
|
| 654 |
+
</span>
|
| 655 |
+
</div>
|
| 656 |
+
{collapsible ? (
|
| 657 |
+
<span className="text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
|
| 658 |
+
) : null}
|
| 659 |
+
</div>
|
| 660 |
+
{!collapsible || open ? (
|
| 661 |
+
<div className="border-t border-neutral-800/60 px-3 py-2">
|
| 662 |
+
{channel.methods.length === 0 ? (
|
| 663 |
+
<div className="text-[11px] text-neutral-600 italic">no callable members</div>
|
| 664 |
+
) : null}
|
| 665 |
+
{channel.methods.map((m) => {
|
| 666 |
+
const fields = m.kind === 'method' ? parseParamFields(m.params) : [];
|
| 667 |
+
return (
|
| 668 |
+
<div key={m.name} className="mb-2 last:mb-0">
|
| 669 |
+
<div className="flex items-center gap-1.5">
|
| 670 |
+
<ActionButton
|
| 671 |
+
disabled={svc === null || busy !== null}
|
| 672 |
+
onClick={() => void invoke(m)}
|
| 673 |
+
>
|
| 674 |
+
{busy === m.name ? '…' : `${m.name}(${m.params})`}
|
| 675 |
+
</ActionButton>
|
| 676 |
+
{m.kind === 'property' ? <Badge tone="neutral">get</Badge> : null}
|
| 677 |
+
</div>
|
| 678 |
+
{fields.length > 0 ? (
|
| 679 |
+
<MethodArgInputs
|
| 680 |
+
fields={fields}
|
| 681 |
+
values={inputs[m.name] ?? {}}
|
| 682 |
+
onChange={(key, value) =>
|
| 683 |
+
setInputs((prev) => ({
|
| 684 |
+
...prev,
|
| 685 |
+
[m.name]: { ...prev[m.name], [key]: value },
|
| 686 |
+
}))
|
| 687 |
+
}
|
| 688 |
+
/>
|
| 689 |
+
) : null}
|
| 690 |
+
{errors[m.name] ? (
|
| 691 |
+
<div className="mt-1">
|
| 692 |
+
<ErrorLine error={errors[m.name]} />
|
| 693 |
+
</div>
|
| 694 |
+
) : null}
|
| 695 |
+
{inlineResults && results[m.name] !== undefined ? (
|
| 696 |
+
<div className="mt-1">
|
| 697 |
+
<JsonView data={results[m.name]} />
|
| 698 |
+
</div>
|
| 699 |
+
) : null}
|
| 700 |
+
</div>
|
| 701 |
+
);
|
| 702 |
+
})}
|
| 703 |
+
</div>
|
| 704 |
+
) : null}
|
| 705 |
+
</div>
|
| 706 |
+
);
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
// ---------------------------------------------------------------------------
|
| 710 |
+
// Structured argument inputs — one labeled field per declared parameter.
|
| 711 |
+
// Values are smart-parsed on invoke (JSON when it parses, plain string
|
| 712 |
+
// otherwise), so simple strings need no quotes.
|
| 713 |
+
// ---------------------------------------------------------------------------
|
| 714 |
+
|
| 715 |
+
function MethodArgInputs({
|
| 716 |
+
fields,
|
| 717 |
+
values,
|
| 718 |
+
onChange,
|
| 719 |
+
}: {
|
| 720 |
+
fields: readonly ParamField[];
|
| 721 |
+
values: Readonly<Record<string, string>>;
|
| 722 |
+
onChange: (key: string, value: string) => void;
|
| 723 |
+
}) {
|
| 724 |
+
return (
|
| 725 |
+
<div className="mt-1 flex flex-col gap-1">
|
| 726 |
+
{fields.map((f, i) => {
|
| 727 |
+
if (f.kind === 'object') {
|
| 728 |
+
return (
|
| 729 |
+
<div key={i}>
|
| 730 |
+
<div className="mb-0.5 font-mono text-[10px] text-neutral-600">{f.name}</div>
|
| 731 |
+
<div className="flex flex-col gap-1 border-l border-neutral-800 pl-2">
|
| 732 |
+
{f.keys.map((key) => (
|
| 733 |
+
<div key={key} className="flex items-center gap-1.5">
|
| 734 |
+
<ArgLabel label={key} />
|
| 735 |
+
<ArgInput
|
| 736 |
+
placeholder="JSON or plain string"
|
| 737 |
+
value={values[fieldKey(i, key)] ?? ''}
|
| 738 |
+
onChange={(v) => onChange(fieldKey(i, key), v)}
|
| 739 |
+
/>
|
| 740 |
+
</div>
|
| 741 |
+
))}
|
| 742 |
+
</div>
|
| 743 |
+
</div>
|
| 744 |
+
);
|
| 745 |
+
}
|
| 746 |
+
if (f.kind === 'value') {
|
| 747 |
+
return (
|
| 748 |
+
<div key={i} className="flex items-center gap-1.5">
|
| 749 |
+
<ArgLabel label={f.name} />
|
| 750 |
+
<ArgInput
|
| 751 |
+
placeholder={
|
| 752 |
+
f.defaultValue !== undefined
|
| 753 |
+
? `default: ${f.defaultValue}`
|
| 754 |
+
: 'JSON or plain string'
|
| 755 |
+
}
|
| 756 |
+
value={values[fieldKey(i)] ?? ''}
|
| 757 |
+
onChange={(v) => onChange(fieldKey(i), v)}
|
| 758 |
+
/>
|
| 759 |
+
</div>
|
| 760 |
+
);
|
| 761 |
+
}
|
| 762 |
+
return (
|
| 763 |
+
<div key={i} className="flex items-center gap-1.5">
|
| 764 |
+
<ArgLabel label={f.label} />
|
| 765 |
+
<ArgInput
|
| 766 |
+
placeholder="arg (JSON)"
|
| 767 |
+
value={values[fieldKey(i)] ?? ''}
|
| 768 |
+
onChange={(v) => onChange(fieldKey(i), v)}
|
| 769 |
+
/>
|
| 770 |
+
</div>
|
| 771 |
+
);
|
| 772 |
+
})}
|
| 773 |
+
</div>
|
| 774 |
+
);
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
function ArgLabel({ label }: { label: string }) {
|
| 778 |
+
return (
|
| 779 |
+
<span
|
| 780 |
+
className="w-28 shrink-0 truncate text-right font-mono text-[10px] text-neutral-500"
|
| 781 |
+
title={label}
|
| 782 |
+
>
|
| 783 |
+
{label}
|
| 784 |
+
</span>
|
| 785 |
+
);
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
function ArgInput({
|
| 789 |
+
placeholder,
|
| 790 |
+
value,
|
| 791 |
+
onChange,
|
| 792 |
+
}: {
|
| 793 |
+
placeholder: string;
|
| 794 |
+
value: string;
|
| 795 |
+
onChange: (value: string) => void;
|
| 796 |
+
}) {
|
| 797 |
+
return (
|
| 798 |
+
<input
|
| 799 |
+
className="min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
| 800 |
+
placeholder={placeholder}
|
| 801 |
+
value={value}
|
| 802 |
+
onChange={(e) => onChange(e.target.value)}
|
| 803 |
+
/>
|
| 804 |
+
);
|
| 805 |
+
}
|
apps/kimi-inspect/src/components/SessionPane.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Session pane — everything session-scoped: the pending-interactions card
|
| 3 |
+
* and the session Service panels under the `Services` tab, plus a `State`
|
| 4 |
+
* tab reading the session's registered plain-data state through
|
| 5 |
+
* `ISessionStateService.snapshot()` (every key a Session Service registered
|
| 6 |
+
* into the session-state container, JSON-safe). Rendered as the `Session`
|
| 7 |
+
* tab of the chat view's right dock (`RightPanel`). The Service panels are
|
| 8 |
+
* fetch-on-demand (no Service-event push channel exists); the State tab
|
| 9 |
+
* instead auto-loads on mount and polls once a second, so it stays live
|
| 10 |
+
* without a Refresh button.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { ISessionStateService } from '@moonshot-ai/agent-core-v2/session/state/sessionState';
|
| 14 |
+
import { useQuery } from '@tanstack/react-query';
|
| 15 |
+
import { useMemo, useState } from 'react';
|
| 16 |
+
|
| 17 |
+
import { serviceByName } from '../channel';
|
| 18 |
+
import { useConnection } from '../connection';
|
| 19 |
+
import { type AnyService } from '../panels';
|
| 20 |
+
import { fetchSessionWorkspaceAssociation } from '../snapshots/api';
|
| 21 |
+
import { InteractionsCard } from './InteractionsCard';
|
| 22 |
+
import { ScopePanels } from './ServicePanels';
|
| 23 |
+
import { StateCard } from './StateCard';
|
| 24 |
+
|
| 25 |
+
type Tab = 'services' | 'state';
|
| 26 |
+
|
| 27 |
+
export function SessionPane({ sessionId, ready }: { sessionId: string | null; ready: boolean }) {
|
| 28 |
+
const { klient } = useConnection();
|
| 29 |
+
const [tab, setTab] = useState<Tab>('services');
|
| 30 |
+
const association = useQuery({
|
| 31 |
+
queryKey: ['session-workspace-association', klient.baseUrl, sessionId],
|
| 32 |
+
queryFn: () => fetchSessionWorkspaceAssociation(klient, sessionId as string),
|
| 33 |
+
enabled: sessionId !== null && ready,
|
| 34 |
+
refetchInterval: 1_000,
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const proxyFor = useMemo(() => {
|
| 38 |
+
return (name: string): AnyService | null => {
|
| 39 |
+
return (
|
| 40 |
+
serviceByName<AnyService>(klient, name, {
|
| 41 |
+
scope: 'session',
|
| 42 |
+
sessionId: sessionId !== null && ready ? sessionId : undefined,
|
| 43 |
+
}) ?? null
|
| 44 |
+
);
|
| 45 |
+
};
|
| 46 |
+
}, [klient, sessionId, ready]);
|
| 47 |
+
|
| 48 |
+
const blocked = sessionId === null || !ready;
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<div className="flex h-full min-h-0 flex-1 flex-col">
|
| 52 |
+
<div className="flex border-b border-neutral-800 text-[11px]">
|
| 53 |
+
{(['services', 'state'] as const).map((t) => (
|
| 54 |
+
<button
|
| 55 |
+
key={t}
|
| 56 |
+
className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
|
| 57 |
+
tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
|
| 58 |
+
}`}
|
| 59 |
+
onClick={() => setTab(t)}
|
| 60 |
+
>
|
| 61 |
+
{t === 'services' ? 'Services' : 'State'}
|
| 62 |
+
</button>
|
| 63 |
+
))}
|
| 64 |
+
</div>
|
| 65 |
+
<div className="flex-1 overflow-y-auto p-3">
|
| 66 |
+
{blocked ? (
|
| 67 |
+
<div className="text-[12px] text-neutral-600">
|
| 68 |
+
{sessionId === null ? 'No session selected.' : 'Loading session…'}
|
| 69 |
+
</div>
|
| 70 |
+
) : (
|
| 71 |
+
<>
|
| 72 |
+
<div className="mb-3 rounded border border-neutral-800 bg-neutral-950/40 p-2 text-[11px]">
|
| 73 |
+
<div className="mb-1 font-semibold uppercase tracking-wider text-neutral-500">Workspace association</div>
|
| 74 |
+
<div className="grid grid-cols-[80px_minmax(0,1fr)] gap-1 font-mono">
|
| 75 |
+
<span className="text-neutral-600">workspace</span>
|
| 76 |
+
<span className="break-all text-neutral-300">{association.data?.workspaceId ?? 'loading…'}</span>
|
| 77 |
+
<span className="text-neutral-600">cwd</span>
|
| 78 |
+
<span className="break-all text-neutral-300">{association.data?.cwd ?? 'loading…'}</span>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
{tab === 'services' ? (
|
| 82 |
+
<>
|
| 83 |
+
<InteractionsCard sessionId={sessionId} />
|
| 84 |
+
<ScopePanels scope="session" proxyFor={proxyFor} />
|
| 85 |
+
</>
|
| 86 |
+
) : (
|
| 87 |
+
<StateCard
|
| 88 |
+
id={sessionId}
|
| 89 |
+
queryKey={['sessionState', sessionId]}
|
| 90 |
+
title="Session state"
|
| 91 |
+
label="sessionStateService"
|
| 92 |
+
fetchSnapshot={() => klient.session(sessionId).service(ISessionStateService).snapshot()}
|
| 93 |
+
/>
|
| 94 |
+
)}
|
| 95 |
+
</>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
}
|
apps/kimi-inspect/src/components/Sidebar.tsx
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Left sidebar — a single-column workspace → session tree backed by the v2
|
| 3 |
+
* list's grouped projection (`GET /api/v2/sessions?view=by_workspace`, see
|
| 4 |
+
* `src/sessions/api.ts`): one request returns every workspace with a matching
|
| 5 |
+
* session, each carrying its first `group.page_size` sessions under the
|
| 6 |
+
* requested sort plus the workspace's full matching total. Preset views
|
| 7 |
+
* (`src/sessions/views.ts`) map onto the endpoint's status / archived / git
|
| 8 |
+
* query conditions; the active view, collapsed workspaces, and the panel
|
| 9 |
+
* width persist to localStorage. Group pagination is the endpoint's opaque
|
| 10 |
+
* cursor (Load more), with a slow poll on top; a workspace whose total
|
| 11 |
+
* outruns the served slice expands inline into the flat per-workspace
|
| 12 |
+
* listing. Live activity frames (`useSessionActivities`) override the REST
|
| 13 |
+
* status badge. Session creation still goes through the v1 REST endpoint.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile';
|
| 17 |
+
import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config';
|
| 18 |
+
import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager';
|
| 19 |
+
import {
|
| 20 |
+
IWorkspaceService,
|
| 21 |
+
type Workspace,
|
| 22 |
+
} from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
|
| 23 |
+
import { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog';
|
| 24 |
+
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
|
| 25 |
+
import { useMemo, useState } from 'react';
|
| 26 |
+
|
| 27 |
+
import type { SessionWorkFacts } from '../activity/store';
|
| 28 |
+
import { useSessionActivities } from '../activity/useSessionActivity';
|
| 29 |
+
import type { InspectClient } from '../channel';
|
| 30 |
+
import { useConnection } from '../connection';
|
| 31 |
+
import {
|
| 32 |
+
fetchV2SessionGroups,
|
| 33 |
+
fetchV2SessionsPage,
|
| 34 |
+
type V2ActivityStatus,
|
| 35 |
+
type V2Session,
|
| 36 |
+
type V2SessionGroup,
|
| 37 |
+
type V2SessionSort,
|
| 38 |
+
} from '../sessions/api';
|
| 39 |
+
import { SESSION_VIEWS, sessionViewById, type SessionView } from '../sessions/views';
|
| 40 |
+
import { Badge, ErrorLine, relTime } from '../ui';
|
| 41 |
+
|
| 42 |
+
const STORAGE_KEY = 'kimi-inspect.session-table';
|
| 43 |
+
|
| 44 |
+
const DEFAULT_WIDTH = 320;
|
| 45 |
+
const MIN_WIDTH = 240;
|
| 46 |
+
const MAX_WIDTH = 640;
|
| 47 |
+
const GROUP_PAGE_SIZE = 50;
|
| 48 |
+
|
| 49 |
+
// ---------------------------------------------------------------------------
|
| 50 |
+
// Persisted panel prefs
|
| 51 |
+
// ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
interface PanelPrefs {
|
| 54 |
+
readonly view?: string;
|
| 55 |
+
/** Collapsed workspace ids; absent = everything expanded. */
|
| 56 |
+
readonly collapsed?: readonly string[];
|
| 57 |
+
readonly width?: number;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function readPrefs(): PanelPrefs {
|
| 61 |
+
try {
|
| 62 |
+
const raw = localStorage.getItem(STORAGE_KEY);
|
| 63 |
+
if (raw !== null) return JSON.parse(raw) as PanelPrefs;
|
| 64 |
+
} catch {
|
| 65 |
+
// corrupt storage — fall through to defaults
|
| 66 |
+
}
|
| 67 |
+
return {};
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function writePrefs(prefs: PanelPrefs): void {
|
| 71 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// ---------------------------------------------------------------------------
|
| 75 |
+
// Live status override (mirrors the server's mapActivityStatus)
|
| 76 |
+
// ---------------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
function liveStatus(facts: SessionWorkFacts | undefined): V2ActivityStatus | undefined {
|
| 79 |
+
if (facts === undefined) return undefined;
|
| 80 |
+
if (facts.pendingInteraction === 'approval') return 'approval';
|
| 81 |
+
if (facts.pendingInteraction === 'question') return 'question';
|
| 82 |
+
if (facts.busy || facts.mainTurnActive) return 'running';
|
| 83 |
+
if (facts.lastTurnReason === 'failed') return 'failed';
|
| 84 |
+
return 'idle';
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const STATUS_TONES: Record<V2ActivityStatus, 'green' | 'amber' | 'sky' | 'red' | 'neutral'> = {
|
| 88 |
+
running: 'green',
|
| 89 |
+
approval: 'amber',
|
| 90 |
+
question: 'sky',
|
| 91 |
+
failed: 'red',
|
| 92 |
+
idle: 'neutral',
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const SORTS: readonly { readonly id: V2SessionSort; readonly label: string }[] = [
|
| 96 |
+
{ id: 'meta.updated_at_desc', label: 'Updated ↓' },
|
| 97 |
+
{ id: 'meta.updated_at_asc', label: 'Updated ↑' },
|
| 98 |
+
{ id: 'meta.created_at_desc', label: 'Created ↓' },
|
| 99 |
+
];
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Default model for a fresh session: the configured global `defaultModel`
|
| 103 |
+
* first (the same fallback the profile bind uses), then the first connected
|
| 104 |
+
* provider's `default_model`. `undefined` means the server has nothing to
|
| 105 |
+
* offer — the session stays model-less and the chat surfaces
|
| 106 |
+
* `model.not_configured` as before.
|
| 107 |
+
*/
|
| 108 |
+
async function resolveDefaultModel(klient: InspectClient): Promise<string | undefined> {
|
| 109 |
+
const configured: unknown = await klient.core(IConfigService).get('defaultModel');
|
| 110 |
+
if (typeof configured === 'string' && configured !== '') return configured;
|
| 111 |
+
const providers = await klient.core(IModelCatalog).listProviders();
|
| 112 |
+
const withDefault = providers.filter((p) => p.default_model !== undefined);
|
| 113 |
+
return (withDefault.find((p) => p.status === 'connected') ?? withDefault[0])?.default_model;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// ---------------------------------------------------------------------------
|
| 117 |
+
// Sidebar
|
| 118 |
+
// ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
export function Sidebar({
|
| 121 |
+
activeSessionId,
|
| 122 |
+
onSelectSession,
|
| 123 |
+
}: {
|
| 124 |
+
activeSessionId: string | null;
|
| 125 |
+
onSelectSession: (sessionId: string) => void;
|
| 126 |
+
}) {
|
| 127 |
+
const { klient, baseUrl, config } = useConnection();
|
| 128 |
+
const queryClient = useQueryClient();
|
| 129 |
+
const activities = useSessionActivities();
|
| 130 |
+
|
| 131 |
+
const [prefs, setPrefs] = useState<PanelPrefs>(readPrefs);
|
| 132 |
+
const view = sessionViewById(prefs.view);
|
| 133 |
+
const [sort, setSort] = useState<V2SessionSort>('meta.updated_at_desc');
|
| 134 |
+
const width = prefs.width ?? DEFAULT_WIDTH;
|
| 135 |
+
const collapsed = useMemo(() => new Set(prefs.collapsed ?? []), [prefs.collapsed]);
|
| 136 |
+
|
| 137 |
+
const updatePrefs = (patch: PanelPrefs) => {
|
| 138 |
+
setPrefs((prev) => {
|
| 139 |
+
const next: PanelPrefs = { ...prev, ...patch };
|
| 140 |
+
writePrefs(next);
|
| 141 |
+
return next;
|
| 142 |
+
});
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
const toggleCollapsed = (workspaceId: string) => {
|
| 146 |
+
const next = new Set(collapsed);
|
| 147 |
+
if (next.has(workspaceId)) next.delete(workspaceId);
|
| 148 |
+
else next.add(workspaceId);
|
| 149 |
+
updatePrefs({ collapsed: [...next] });
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
const token = config.token.trim();
|
| 153 |
+
const authToken = token === '' ? undefined : token;
|
| 154 |
+
|
| 155 |
+
const workspaces = useQuery({
|
| 156 |
+
queryKey: ['workspaces'],
|
| 157 |
+
queryFn: () => klient.core(IWorkspaceService).list(),
|
| 158 |
+
refetchInterval: 15_000,
|
| 159 |
+
});
|
| 160 |
+
const workspaceNames = useMemo(
|
| 161 |
+
() => new Map((workspaces.data ?? []).map((ws) => [ws.id, ws.name] as const)),
|
| 162 |
+
[workspaces.data],
|
| 163 |
+
);
|
| 164 |
+
|
| 165 |
+
const groups = useInfiniteQuery({
|
| 166 |
+
queryKey: ['v2-sessions', 'tree', view.id, sort],
|
| 167 |
+
queryFn: ({ pageParam }) =>
|
| 168 |
+
fetchV2SessionGroups({
|
| 169 |
+
baseUrl,
|
| 170 |
+
token: authToken,
|
| 171 |
+
statuses: view.statuses,
|
| 172 |
+
archived: view.archived,
|
| 173 |
+
includeGit: view.includeGit,
|
| 174 |
+
sort,
|
| 175 |
+
pageSize: 50,
|
| 176 |
+
groupPageSize: GROUP_PAGE_SIZE,
|
| 177 |
+
pageToken: pageParam,
|
| 178 |
+
}),
|
| 179 |
+
initialPageParam: undefined as string | undefined,
|
| 180 |
+
getNextPageParam: (last) => last.nextPageToken,
|
| 181 |
+
refetchInterval: 15_000,
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
const groupList = useMemo(
|
| 185 |
+
() => groups.data?.pages.flatMap((page) => page.groups) ?? [],
|
| 186 |
+
[groups.data],
|
| 187 |
+
);
|
| 188 |
+
|
| 189 |
+
const createSession = async (ws: Workspace | null) => {
|
| 190 |
+
// With a workspace, the server derives workDir from workspace.root, so no cwd is needed.
|
| 191 |
+
let body: string;
|
| 192 |
+
if (ws !== null) {
|
| 193 |
+
body = JSON.stringify({ workspace_id: ws.id });
|
| 194 |
+
} else {
|
| 195 |
+
const cwd = window.prompt('Working directory for the new session:', '');
|
| 196 |
+
if (cwd === null || cwd.trim() === '') return;
|
| 197 |
+
body = JSON.stringify({ metadata: { cwd: cwd.trim() } });
|
| 198 |
+
}
|
| 199 |
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
| 200 |
+
if (token !== '') headers['authorization'] = `Bearer ${token}`;
|
| 201 |
+
const res = await fetch(`${baseUrl}/api/v1/sessions`, {
|
| 202 |
+
method: 'POST',
|
| 203 |
+
headers,
|
| 204 |
+
body,
|
| 205 |
+
});
|
| 206 |
+
const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } };
|
| 207 |
+
if (envelope.code !== 0) {
|
| 208 |
+
window.alert(`create session failed: ${envelope.msg}`);
|
| 209 |
+
return;
|
| 210 |
+
}
|
| 211 |
+
const sessionId = envelope.data.id;
|
| 212 |
+
// The REST create route ignores agent_config, so bind the default model
|
| 213 |
+
// over the channel — the same resume + setModel path the Model Catalog's
|
| 214 |
+
// "+ Session" button uses. Best-effort: a failure leaves the session
|
| 215 |
+
// model-less instead of blocking the creation flow.
|
| 216 |
+
try {
|
| 217 |
+
const model = await resolveDefaultModel(klient);
|
| 218 |
+
if (model !== undefined) {
|
| 219 |
+
await klient.core(ISessionManager).resume(sessionId);
|
| 220 |
+
await klient.session(sessionId).agent('main').service(IAgentProfileService).setModel(model);
|
| 221 |
+
}
|
| 222 |
+
} catch (error) {
|
| 223 |
+
console.warn('failed to set the default model on the new session', error);
|
| 224 |
+
}
|
| 225 |
+
await queryClient.invalidateQueries({ queryKey: ['v2-sessions'] });
|
| 226 |
+
onSelectSession(sessionId);
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
+
const cycleSort = () => {
|
| 230 |
+
const index = SORTS.findIndex((s) => s.id === sort);
|
| 231 |
+
setSort(SORTS[(index + 1) % SORTS.length]!.id);
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
const startResize = (e: React.MouseEvent) => {
|
| 235 |
+
e.preventDefault();
|
| 236 |
+
const startX = e.clientX;
|
| 237 |
+
const startWidth = width;
|
| 238 |
+
const onMove = (ev: MouseEvent) => {
|
| 239 |
+
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + ev.clientX - startX));
|
| 240 |
+
setPrefs((prev) => ({ ...prev, width: next }));
|
| 241 |
+
};
|
| 242 |
+
const onUp = (ev: MouseEvent) => {
|
| 243 |
+
window.removeEventListener('mousemove', onMove);
|
| 244 |
+
window.removeEventListener('mouseup', onUp);
|
| 245 |
+
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + ev.clientX - startX));
|
| 246 |
+
updatePrefs({ width: next });
|
| 247 |
+
};
|
| 248 |
+
window.addEventListener('mousemove', onMove);
|
| 249 |
+
window.addEventListener('mouseup', onUp);
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
return (
|
| 253 |
+
<div
|
| 254 |
+
className="relative flex h-full shrink-0 flex-col border-r border-neutral-800"
|
| 255 |
+
style={{ width }}
|
| 256 |
+
>
|
| 257 |
+
{/* View tabs */}
|
| 258 |
+
<div className="flex items-center gap-1 overflow-x-auto border-b border-neutral-800 px-2 py-1.5">
|
| 259 |
+
{SESSION_VIEWS.map((v) => (
|
| 260 |
+
<button
|
| 261 |
+
key={v.id}
|
| 262 |
+
className={`shrink-0 rounded px-2 py-0.5 text-[11px] ${
|
| 263 |
+
v.id === view.id
|
| 264 |
+
? 'bg-neutral-800 font-medium text-neutral-100'
|
| 265 |
+
: 'text-neutral-500 hover:text-neutral-300'
|
| 266 |
+
}`}
|
| 267 |
+
onClick={() => updatePrefs({ view: v.id })}
|
| 268 |
+
>
|
| 269 |
+
{v.label}
|
| 270 |
+
</button>
|
| 271 |
+
))}
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
{/* Toolbar: new session + sort */}
|
| 275 |
+
<div className="flex items-center justify-between border-b border-neutral-800 px-2 py-1">
|
| 276 |
+
<NewSessionMenu workspaces={workspaces.data ?? []} onCreate={createSession} />
|
| 277 |
+
<button
|
| 278 |
+
className="rounded border border-neutral-700 px-2 py-0.5 text-[11px] text-neutral-400 hover:bg-neutral-800"
|
| 279 |
+
title="Click to change sort"
|
| 280 |
+
onClick={cycleSort}
|
| 281 |
+
>
|
| 282 |
+
{SORTS.find((s) => s.id === sort)?.label}
|
| 283 |
+
</button>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
{/* Tree body */}
|
| 287 |
+
<div className="flex-1 overflow-y-auto">
|
| 288 |
+
{groups.isError ? <ErrorLine error={groups.error} /> : null}
|
| 289 |
+
{groupList.map((group) => (
|
| 290 |
+
<WorkspaceNode
|
| 291 |
+
key={group.workspace.id}
|
| 292 |
+
group={group}
|
| 293 |
+
view={view}
|
| 294 |
+
sort={sort}
|
| 295 |
+
collapsed={collapsed.has(group.workspace.id)}
|
| 296 |
+
onToggleCollapsed={() => toggleCollapsed(group.workspace.id)}
|
| 297 |
+
workspaceNames={workspaceNames}
|
| 298 |
+
activeSessionId={activeSessionId}
|
| 299 |
+
activityOf={(id) => activities.get(id)}
|
| 300 |
+
onSelect={onSelectSession}
|
| 301 |
+
baseUrl={baseUrl}
|
| 302 |
+
token={authToken}
|
| 303 |
+
/>
|
| 304 |
+
))}
|
| 305 |
+
{groups.isLoading ? (
|
| 306 |
+
<div className="px-3 py-2 text-[11px] text-neutral-600">loading…</div>
|
| 307 |
+
) : null}
|
| 308 |
+
{!groups.isLoading && groupList.length === 0 && !groups.isError ? (
|
| 309 |
+
<div className="px-3 py-2 text-[11px] text-neutral-600">no sessions</div>
|
| 310 |
+
) : null}
|
| 311 |
+
{groups.hasNextPage ? (
|
| 312 |
+
<button
|
| 313 |
+
className="w-full border-t border-neutral-800 px-3 py-1.5 text-[11px] text-sky-500 hover:bg-neutral-800/60 hover:text-sky-400"
|
| 314 |
+
disabled={groups.isFetchingNextPage}
|
| 315 |
+
onClick={() => void groups.fetchNextPage()}
|
| 316 |
+
>
|
| 317 |
+
{groups.isFetchingNextPage ? 'loading…' : 'Load more workspaces'}
|
| 318 |
+
</button>
|
| 319 |
+
) : null}
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
{/* Resize handle */}
|
| 323 |
+
<div
|
| 324 |
+
className="absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-sky-700/50"
|
| 325 |
+
onMouseDown={startResize}
|
| 326 |
+
/>
|
| 327 |
+
</div>
|
| 328 |
+
);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// ---------------------------------------------------------------------------
|
| 332 |
+
// Tree nodes
|
| 333 |
+
// ---------------------------------------------------------------------------
|
| 334 |
+
|
| 335 |
+
function WorkspaceNode({
|
| 336 |
+
group,
|
| 337 |
+
view,
|
| 338 |
+
sort,
|
| 339 |
+
collapsed,
|
| 340 |
+
onToggleCollapsed,
|
| 341 |
+
workspaceNames,
|
| 342 |
+
activeSessionId,
|
| 343 |
+
activityOf,
|
| 344 |
+
onSelect,
|
| 345 |
+
baseUrl,
|
| 346 |
+
token,
|
| 347 |
+
}: {
|
| 348 |
+
group: V2SessionGroup;
|
| 349 |
+
view: SessionView;
|
| 350 |
+
sort: V2SessionSort;
|
| 351 |
+
collapsed: boolean;
|
| 352 |
+
onToggleCollapsed: () => void;
|
| 353 |
+
workspaceNames: ReadonlyMap<string, string>;
|
| 354 |
+
activeSessionId: string | null;
|
| 355 |
+
activityOf: (sessionId: string) => SessionWorkFacts | undefined;
|
| 356 |
+
onSelect: (sessionId: string) => void;
|
| 357 |
+
baseUrl: string;
|
| 358 |
+
token?: string | undefined;
|
| 359 |
+
}) {
|
| 360 |
+
const [showAll, setShowAll] = useState(false);
|
| 361 |
+
const hasMore = group.total > group.sessions.length;
|
| 362 |
+
return (
|
| 363 |
+
<div>
|
| 364 |
+
<div
|
| 365 |
+
className="flex cursor-pointer items-center gap-1.5 border-b border-neutral-800 px-2 py-1.5 select-none hover:bg-neutral-800/60"
|
| 366 |
+
onClick={onToggleCollapsed}
|
| 367 |
+
title={group.workspace.cwd ?? group.workspace.id}
|
| 368 |
+
>
|
| 369 |
+
<span className="w-3 shrink-0 text-center text-[9px] text-neutral-600">
|
| 370 |
+
{collapsed ? '▸' : '▾'}
|
| 371 |
+
</span>
|
| 372 |
+
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold text-neutral-300">
|
| 373 |
+
{workspaceNames.get(group.workspace.id) ?? group.workspace.cwd ?? group.workspace.id}
|
| 374 |
+
</span>
|
| 375 |
+
<span className="shrink-0 text-[10px] text-neutral-600">{group.total}</span>
|
| 376 |
+
</div>
|
| 377 |
+
{collapsed ? null : (
|
| 378 |
+
<>
|
| 379 |
+
{showAll ? null : (
|
| 380 |
+
<>
|
| 381 |
+
{group.sessions.map((s) => (
|
| 382 |
+
<SessionNode
|
| 383 |
+
key={s.id}
|
| 384 |
+
s={s}
|
| 385 |
+
active={s.id === activeSessionId}
|
| 386 |
+
activity={activityOf(s.id)}
|
| 387 |
+
onClick={() => onSelect(s.id)}
|
| 388 |
+
/>
|
| 389 |
+
))}
|
| 390 |
+
{hasMore ? (
|
| 391 |
+
<button
|
| 392 |
+
className="w-full border-b border-neutral-800/50 py-1 pl-6 text-left text-[10px] text-sky-500 hover:bg-neutral-800/60 hover:text-sky-400"
|
| 393 |
+
onClick={() => setShowAll(true)}
|
| 394 |
+
>
|
| 395 |
+
Show all {group.total}…
|
| 396 |
+
</button>
|
| 397 |
+
) : null}
|
| 398 |
+
</>
|
| 399 |
+
)}
|
| 400 |
+
{showAll ? (
|
| 401 |
+
<FullGroupList
|
| 402 |
+
workspaceId={group.workspace.id}
|
| 403 |
+
view={view}
|
| 404 |
+
sort={sort}
|
| 405 |
+
baseUrl={baseUrl}
|
| 406 |
+
token={token}
|
| 407 |
+
activeSessionId={activeSessionId}
|
| 408 |
+
activityOf={activityOf}
|
| 409 |
+
onSelect={onSelect}
|
| 410 |
+
/>
|
| 411 |
+
) : null}
|
| 412 |
+
</>
|
| 413 |
+
)}
|
| 414 |
+
</div>
|
| 415 |
+
);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
/**
|
| 419 |
+
* The flat per-workspace listing behind "Show all" — pages the ungrouped
|
| 420 |
+
* projection filtered to this workspace, so sessions beyond the grouped
|
| 421 |
+
* slice stay reachable.
|
| 422 |
+
*/
|
| 423 |
+
function FullGroupList({
|
| 424 |
+
workspaceId,
|
| 425 |
+
view,
|
| 426 |
+
sort,
|
| 427 |
+
baseUrl,
|
| 428 |
+
token,
|
| 429 |
+
activeSessionId,
|
| 430 |
+
activityOf,
|
| 431 |
+
onSelect,
|
| 432 |
+
}: {
|
| 433 |
+
workspaceId: string;
|
| 434 |
+
view: SessionView;
|
| 435 |
+
sort: V2SessionSort;
|
| 436 |
+
baseUrl: string;
|
| 437 |
+
token?: string | undefined;
|
| 438 |
+
activeSessionId: string | null;
|
| 439 |
+
activityOf: (sessionId: string) => SessionWorkFacts | undefined;
|
| 440 |
+
onSelect: (sessionId: string) => void;
|
| 441 |
+
}) {
|
| 442 |
+
const sessions = useInfiniteQuery({
|
| 443 |
+
queryKey: ['v2-sessions', 'tree-full', workspaceId, view.id, sort],
|
| 444 |
+
queryFn: ({ pageParam }) =>
|
| 445 |
+
fetchV2SessionsPage({
|
| 446 |
+
baseUrl,
|
| 447 |
+
token,
|
| 448 |
+
workspaceIds: [workspaceId],
|
| 449 |
+
statuses: view.statuses,
|
| 450 |
+
archived: view.archived,
|
| 451 |
+
includeGit: view.includeGit,
|
| 452 |
+
sort,
|
| 453 |
+
pageSize: 50,
|
| 454 |
+
pageToken: pageParam,
|
| 455 |
+
}),
|
| 456 |
+
initialPageParam: undefined as string | undefined,
|
| 457 |
+
getNextPageParam: (last) => last.nextPageToken,
|
| 458 |
+
});
|
| 459 |
+
const seen = new Set<string>();
|
| 460 |
+
const items = (sessions.data?.pages.flatMap((page) => page.items) ?? []).filter((s) => {
|
| 461 |
+
if (seen.has(s.id)) return false;
|
| 462 |
+
seen.add(s.id);
|
| 463 |
+
return true;
|
| 464 |
+
});
|
| 465 |
+
return (
|
| 466 |
+
<div className="bg-neutral-950/40">
|
| 467 |
+
{items.map((s) => (
|
| 468 |
+
<SessionNode
|
| 469 |
+
key={s.id}
|
| 470 |
+
s={s}
|
| 471 |
+
active={s.id === activeSessionId}
|
| 472 |
+
activity={activityOf(s.id)}
|
| 473 |
+
onClick={() => onSelect(s.id)}
|
| 474 |
+
/>
|
| 475 |
+
))}
|
| 476 |
+
{sessions.isLoading ? (
|
| 477 |
+
<div className="py-1 pl-6 text-[10px] text-neutral-600">loading…</div>
|
| 478 |
+
) : null}
|
| 479 |
+
{sessions.hasNextPage ? (
|
| 480 |
+
<button
|
| 481 |
+
className="w-full py-1 pl-6 text-left text-[10px] text-sky-500 hover:bg-neutral-800/60 hover:text-sky-400"
|
| 482 |
+
disabled={sessions.isFetchingNextPage}
|
| 483 |
+
onClick={() => void sessions.fetchNextPage()}
|
| 484 |
+
>
|
| 485 |
+
{sessions.isFetchingNextPage ? 'loading…' : 'Load more'}
|
| 486 |
+
</button>
|
| 487 |
+
) : null}
|
| 488 |
+
</div>
|
| 489 |
+
);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function SessionNode({
|
| 493 |
+
s,
|
| 494 |
+
active,
|
| 495 |
+
activity,
|
| 496 |
+
onClick,
|
| 497 |
+
}: {
|
| 498 |
+
s: V2Session;
|
| 499 |
+
active: boolean;
|
| 500 |
+
activity?: SessionWorkFacts | undefined;
|
| 501 |
+
onClick: () => void;
|
| 502 |
+
}) {
|
| 503 |
+
const status = liveStatus(activity) ?? s.activity.status;
|
| 504 |
+
return (
|
| 505 |
+
<div
|
| 506 |
+
className={`cursor-pointer border-b border-neutral-800/50 py-1 pr-2 pl-6 hover:bg-neutral-800/60 ${
|
| 507 |
+
active ? 'bg-sky-950/60' : ''
|
| 508 |
+
}`}
|
| 509 |
+
onClick={onClick}
|
| 510 |
+
>
|
| 511 |
+
<div className="flex items-center gap-1.5">
|
| 512 |
+
{status === 'idle' ? null : <Badge tone={STATUS_TONES[status]}>{status}</Badge>}
|
| 513 |
+
<span className="min-w-0 flex-1 truncate text-[12px] text-neutral-200">
|
| 514 |
+
{s.meta.title ?? s.meta.lastPrompt ?? s.id}
|
| 515 |
+
</span>
|
| 516 |
+
{s.meta.archived ? <Badge tone="neutral">archived</Badge> : null}
|
| 517 |
+
<span className="shrink-0 text-[10px] text-neutral-500">{relTime(s.meta.updatedAt)}</span>
|
| 518 |
+
</div>
|
| 519 |
+
<div className="flex items-center gap-2 truncate font-mono text-[10px] text-neutral-600">
|
| 520 |
+
<span className="truncate">{s.id.slice(0, 12)}</span>
|
| 521 |
+
{s.git !== undefined && s.git.branch !== null ? (
|
| 522 |
+
<span className="truncate" title={s.git.branch}>
|
| 523 |
+
{s.git.branch}
|
| 524 |
+
</span>
|
| 525 |
+
) : null}
|
| 526 |
+
{s.git !== undefined && s.git.pullRequest !== null ? (
|
| 527 |
+
<a
|
| 528 |
+
className="shrink-0 text-sky-500 hover:text-sky-400"
|
| 529 |
+
href={s.git.pullRequest.url}
|
| 530 |
+
target="_blank"
|
| 531 |
+
rel="noreferrer"
|
| 532 |
+
onClick={(e) => e.stopPropagation()}
|
| 533 |
+
>
|
| 534 |
+
#{s.git.pullRequest.number}
|
| 535 |
+
</a>
|
| 536 |
+
) : null}
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
);
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
// ---------------------------------------------------------------------------
|
| 543 |
+
// Toolbar dropdowns
|
| 544 |
+
// ---------------------------------------------------------------------------
|
| 545 |
+
|
| 546 |
+
function useDropdown(): { open: boolean; toggle: () => void; close: () => void } {
|
| 547 |
+
const [open, setOpen] = useState(false);
|
| 548 |
+
return {
|
| 549 |
+
open,
|
| 550 |
+
toggle: () => setOpen((v) => !v),
|
| 551 |
+
close: () => setOpen(false),
|
| 552 |
+
};
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
function NewSessionMenu({
|
| 556 |
+
workspaces,
|
| 557 |
+
onCreate,
|
| 558 |
+
}: {
|
| 559 |
+
workspaces: readonly Workspace[];
|
| 560 |
+
onCreate: (ws: Workspace | null) => Promise<void>;
|
| 561 |
+
}) {
|
| 562 |
+
const { open, toggle, close } = useDropdown();
|
| 563 |
+
return (
|
| 564 |
+
<div className="relative">
|
| 565 |
+
<button
|
| 566 |
+
className="rounded border border-neutral-700 px-2 py-0.5 text-[11px] text-neutral-300 hover:bg-neutral-800"
|
| 567 |
+
onClick={toggle}
|
| 568 |
+
>
|
| 569 |
+
+ New session
|
| 570 |
+
</button>
|
| 571 |
+
{open ? (
|
| 572 |
+
<>
|
| 573 |
+
<div className="fixed inset-0 z-10" onClick={close} />
|
| 574 |
+
<div className="absolute left-0 z-20 mt-1 w-64 rounded border border-neutral-700 bg-neutral-900 py-1 shadow-xl">
|
| 575 |
+
{workspaces.map((ws) => (
|
| 576 |
+
<button
|
| 577 |
+
key={ws.id}
|
| 578 |
+
className="block w-full truncate px-3 py-1.5 text-left text-[11px] text-neutral-200 hover:bg-neutral-800"
|
| 579 |
+
title={ws.root}
|
| 580 |
+
onClick={() => {
|
| 581 |
+
close();
|
| 582 |
+
void onCreate(ws);
|
| 583 |
+
}}
|
| 584 |
+
>
|
| 585 |
+
{ws.name}
|
| 586 |
+
</button>
|
| 587 |
+
))}
|
| 588 |
+
<button
|
| 589 |
+
className="block w-full border-t border-neutral-800 px-3 py-1.5 text-left text-[11px] text-neutral-400 hover:bg-neutral-800"
|
| 590 |
+
onClick={() => {
|
| 591 |
+
close();
|
| 592 |
+
void onCreate(null);
|
| 593 |
+
}}
|
| 594 |
+
>
|
| 595 |
+
No workspace (prompt for cwd)…
|
| 596 |
+
</button>
|
| 597 |
+
</div>
|
| 598 |
+
</>
|
| 599 |
+
) : null}
|
| 600 |
+
</div>
|
| 601 |
+
);
|
| 602 |
+
}
|
apps/kimi-inspect/src/components/StateCard.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* State card — one scope's registered state as a live diff tree, shared by
|
| 3 |
+
* the session `State` tab (`SessionPane`) and the agent `State` tab
|
| 4 |
+
* (`RightPanel`). Same view as the audit panel's "Diff vs prev" (`StateTree`
|
| 5 |
+
* over a `DiffNode` root), so every key is an interactive, collapsible row.
|
| 6 |
+
* Auto-loads on mount and polls once a second: the first snapshot (and any
|
| 7 |
+
* owner switch) renders as a plain tree, each later snapshot folds in as a
|
| 8 |
+
* structural diff against the previous one (added = green, removed = red,
|
| 9 |
+
* modified = amber). Changed subtrees auto-open; rows the user expanded stay
|
| 10 |
+
* expanded across polls.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { useQuery } from '@tanstack/react-query';
|
| 14 |
+
import { useEffect, useState, type ReactNode } from 'react';
|
| 15 |
+
|
| 16 |
+
import { diffValue, type DiffNode } from '../audit/diff';
|
| 17 |
+
import { ActionButton, Badge, ErrorLine } from '../ui';
|
| 18 |
+
import { StateTree, plainNode } from './audit/StateTree';
|
| 19 |
+
|
| 20 |
+
export function StateCard({
|
| 21 |
+
id,
|
| 22 |
+
queryKey,
|
| 23 |
+
title,
|
| 24 |
+
label,
|
| 25 |
+
badge,
|
| 26 |
+
fetchSnapshot,
|
| 27 |
+
}: {
|
| 28 |
+
/** Identity of the state owner (`sessionId`, or `sessionId/agentId`); a
|
| 29 |
+
* change resets the diff baseline so a fresh owner renders as a plain tree
|
| 30 |
+
* instead of an all-green diff against the previous owner. */
|
| 31 |
+
id: string;
|
| 32 |
+
queryKey: readonly unknown[];
|
| 33 |
+
/** Card title, e.g. `Session state` / `Agent state`. */
|
| 34 |
+
title: string;
|
| 35 |
+
/** Mono caption next to the title: the state service's channel name. */
|
| 36 |
+
label: string;
|
| 37 |
+
badge?: ReactNode;
|
| 38 |
+
fetchSnapshot: () => Promise<Record<string, unknown>>;
|
| 39 |
+
}) {
|
| 40 |
+
const query = useQuery({
|
| 41 |
+
queryKey,
|
| 42 |
+
queryFn: async () => ({ id, snapshot: await fetchSnapshot() }),
|
| 43 |
+
refetchInterval: 1000,
|
| 44 |
+
placeholderData: (previous) => previous,
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
// Fold each poll into a diff against the previous snapshot. `plainNode`
|
| 48 |
+
// on first load / owner switch — diffing a freshly loaded tree against
|
| 49 |
+
// nothing (or against the previous owner) would paint it all-green. While
|
| 50 |
+
// the new owner's fetch is in flight the query still carries the previous
|
| 51 |
+
// owner's placeholder data (`data.id !== id`); ignore it so a stale tree
|
| 52 |
+
// never renders under the new owner's badge.
|
| 53 |
+
const [tree, setTree] = useState<{ id: string; node: DiffNode } | null>(null);
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
const data = query.data;
|
| 56 |
+
if (data === undefined || data.id !== id) return;
|
| 57 |
+
setTree((prev) =>
|
| 58 |
+
prev === null || prev.id !== data.id
|
| 59 |
+
? { id: data.id, node: plainNode(data.snapshot) }
|
| 60 |
+
: { id: data.id, node: diffValue(prev.node.value, data.snapshot) },
|
| 61 |
+
);
|
| 62 |
+
}, [query.data, id]);
|
| 63 |
+
|
| 64 |
+
// The tree is only shown when it belongs to the current owner — after an
|
| 65 |
+
// owner switch the old tree is suppressed until the new snapshot arrives.
|
| 66 |
+
const visibleTree = tree !== null && tree.id === id ? tree : null;
|
| 67 |
+
|
| 68 |
+
// One-click expand/collapse: bumping `nonce` remounts the tree, and the
|
| 69 |
+
// fresh `defaultDepth` decides how deep nodes open (Infinity = expand all,
|
| 70 |
+
// 0 = collapse all — diff-changed subtrees still auto-open, as in the
|
| 71 |
+
// audit panel). Polls afterwards keep the local open state untouched.
|
| 72 |
+
const [expand, setExpand] = useState({ nonce: 0, depth: 1 });
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
|
| 76 |
+
<div className="flex items-center justify-between border-b border-neutral-800/60 px-3 py-2">
|
| 77 |
+
<div>
|
| 78 |
+
<span className="text-[12px] font-medium text-neutral-200">{title}</span>
|
| 79 |
+
<span className="ml-2 font-mono text-[10px] text-neutral-600">{label}</span>
|
| 80 |
+
{badge}
|
| 81 |
+
{visibleTree !== null ? (
|
| 82 |
+
<Badge tone="neutral">
|
| 83 |
+
{Object.keys(visibleTree.node.value as Record<string, unknown>).length} keys
|
| 84 |
+
</Badge>
|
| 85 |
+
) : null}
|
| 86 |
+
</div>
|
| 87 |
+
<div className="flex items-center gap-1.5">
|
| 88 |
+
<ActionButton
|
| 89 |
+
onClick={() =>
|
| 90 |
+
setExpand((s) => ({ nonce: s.nonce + 1, depth: Number.POSITIVE_INFINITY }))
|
| 91 |
+
}
|
| 92 |
+
>
|
| 93 |
+
Expand
|
| 94 |
+
</ActionButton>
|
| 95 |
+
<ActionButton onClick={() => setExpand((s) => ({ nonce: s.nonce + 1, depth: 0 }))}>
|
| 96 |
+
Collapse
|
| 97 |
+
</ActionButton>
|
| 98 |
+
<span className="text-[10px] text-neutral-600">live · 1s</span>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div className="px-3 py-2">
|
| 102 |
+
{query.isError ? (
|
| 103 |
+
<div className="mb-2">
|
| 104 |
+
<ErrorLine error={query.error} />
|
| 105 |
+
</div>
|
| 106 |
+
) : null}
|
| 107 |
+
{visibleTree === null ? (
|
| 108 |
+
<div className="text-[11px] text-neutral-600 italic">
|
| 109 |
+
{query.isPending || tree !== null ? 'Loading state…' : 'no state registered'}
|
| 110 |
+
</div>
|
| 111 |
+
) : (
|
| 112 |
+
<StateTree key={expand.nonce} root={visibleTree.node} defaultDepth={expand.depth} />
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
}
|
apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WorkspaceDirBrowser — server-side filesystem browser living in the Workspace
|
| 3 |
+
* Services view's left sidebar, replacing the old <select> of registered
|
| 4 |
+
* workspaces. Directories are listed through the App-scope
|
| 5 |
+
* `IHostFolderBrowser` (home + recent roots + one-level directory listing);
|
| 6 |
+
* entries that are registered workspaces carry a `workspace` badge plus their
|
| 7 |
+
* trust state (`trusted` / `untrusted`, read through the Workspace-scope
|
| 8 |
+
* `IWorkspaceTrust`; servers predating that service simply show no trust
|
| 9 |
+
* badge). "Select" registers the current folder on demand —
|
| 10 |
+
* `IWorkspaceService.createOrTouch` is idempotent on root — before handing
|
| 11 |
+
* the workspace to the parent.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import {
|
| 15 |
+
IHostFolderBrowser,
|
| 16 |
+
type FsBrowseEntry,
|
| 17 |
+
} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser';
|
| 18 |
+
import {
|
| 19 |
+
IWorkspaceService,
|
| 20 |
+
type Workspace,
|
| 21 |
+
} from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
|
| 22 |
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
| 23 |
+
import { useState } from 'react';
|
| 24 |
+
|
| 25 |
+
import type { InspectClient } from '../channel';
|
| 26 |
+
import { fetchWorkspaceSnapshot } from '../snapshots/api';
|
| 27 |
+
import { ErrorLine } from '../ui';
|
| 28 |
+
|
| 29 |
+
function normalizePath(path: string): string {
|
| 30 |
+
return path.replaceAll('\\', '/').replace(/\/+$/, '');
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function baseName(path: string): string {
|
| 34 |
+
const normalized = normalizePath(path);
|
| 35 |
+
return normalized.split('/').pop() ?? normalized;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export function WorkspaceDirBrowser(props: {
|
| 39 |
+
klient: InspectClient;
|
| 40 |
+
workspaces: readonly Workspace[] | undefined;
|
| 41 |
+
onSelect: (workspace: Workspace) => void;
|
| 42 |
+
}) {
|
| 43 |
+
const { klient, workspaces, onSelect } = props;
|
| 44 |
+
const queryClient = useQueryClient();
|
| 45 |
+
/** Current browsed directory; null = the server default ($HOME). */
|
| 46 |
+
const [path, setPath] = useState<string | null>(null);
|
| 47 |
+
|
| 48 |
+
const home = useQuery({
|
| 49 |
+
queryKey: ['fs-home', klient.baseUrl],
|
| 50 |
+
queryFn: () => klient.core(IHostFolderBrowser).home(),
|
| 51 |
+
});
|
| 52 |
+
const browse = useQuery({
|
| 53 |
+
queryKey: ['fs-browse', klient.baseUrl, path],
|
| 54 |
+
queryFn: () =>
|
| 55 |
+
path === null
|
| 56 |
+
? klient.core(IHostFolderBrowser).browse()
|
| 57 |
+
: klient.core(IHostFolderBrowser).browse(path),
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
const select = useMutation({
|
| 61 |
+
mutationFn: (root: string) => klient.core(IWorkspaceService).createOrTouch(root),
|
| 62 |
+
onSuccess: async (workspace) => {
|
| 63 |
+
await queryClient.invalidateQueries({ queryKey: ['workspaces', klient.baseUrl] });
|
| 64 |
+
onSelect(workspace);
|
| 65 |
+
},
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
const workspaceList = workspaces ?? [];
|
| 69 |
+
/** normalized root → trusted; undefined when the server predates `workspaceTrust`. */
|
| 70 |
+
const trustByRoot = useQuery({
|
| 71 |
+
queryKey: ['workspace-trust', klient.baseUrl, workspaceList.map((ws) => ws.id).join(',')],
|
| 72 |
+
enabled: workspaceList.length > 0,
|
| 73 |
+
retry: false,
|
| 74 |
+
queryFn: async () => {
|
| 75 |
+
const entries = await Promise.all(
|
| 76 |
+
workspaceList.map(async (ws) => {
|
| 77 |
+
try {
|
| 78 |
+
const snapshot = await fetchWorkspaceSnapshot(klient, ws.id);
|
| 79 |
+
return [normalizePath(ws.root), snapshot.program.trusted] as const;
|
| 80 |
+
} catch {
|
| 81 |
+
return [normalizePath(ws.root), undefined] as const;
|
| 82 |
+
}
|
| 83 |
+
}),
|
| 84 |
+
);
|
| 85 |
+
return new Map(entries);
|
| 86 |
+
},
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
const workspaceRoots = new Set(workspaceList.map((ws) => normalizePath(ws.root)));
|
| 90 |
+
const current = browse.data ?? null;
|
| 91 |
+
const recents = home.data?.recent_roots ?? [];
|
| 92 |
+
const isCurrentWorkspace = current !== null && workspaceRoots.has(normalizePath(current.path));
|
| 93 |
+
|
| 94 |
+
const renderEntry = (entry: FsBrowseEntry) => {
|
| 95 |
+
const isWorkspace = workspaceRoots.has(normalizePath(entry.path));
|
| 96 |
+
const trusted = trustByRoot.data?.get(normalizePath(entry.path));
|
| 97 |
+
return (
|
| 98 |
+
<button
|
| 99 |
+
key={entry.path}
|
| 100 |
+
type="button"
|
| 101 |
+
title={entry.path}
|
| 102 |
+
onClick={() => {
|
| 103 |
+
setPath(entry.path);
|
| 104 |
+
}}
|
| 105 |
+
className="flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-neutral-900"
|
| 106 |
+
>
|
| 107 |
+
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-neutral-200">
|
| 108 |
+
{entry.name}/
|
| 109 |
+
</span>
|
| 110 |
+
{isWorkspace && trusted !== undefined ? (
|
| 111 |
+
<span
|
| 112 |
+
className={`shrink-0 rounded border px-1 text-[9px] font-semibold uppercase tracking-wider ${
|
| 113 |
+
trusted
|
| 114 |
+
? 'border-emerald-800 text-emerald-400'
|
| 115 |
+
: 'border-amber-800 text-amber-400'
|
| 116 |
+
}`}
|
| 117 |
+
>
|
| 118 |
+
{trusted ? 'trusted' : 'untrusted'}
|
| 119 |
+
</span>
|
| 120 |
+
) : null}
|
| 121 |
+
{isWorkspace ? (
|
| 122 |
+
<span className="shrink-0 rounded border border-sky-800 px-1 text-[9px] font-semibold uppercase tracking-wider text-sky-400">
|
| 123 |
+
workspace
|
| 124 |
+
</span>
|
| 125 |
+
) : null}
|
| 126 |
+
</button>
|
| 127 |
+
);
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
return (
|
| 131 |
+
<div className="flex min-h-0 flex-1 flex-col">
|
| 132 |
+
<div className="flex flex-col gap-1 border-b border-neutral-800 px-3 py-2">
|
| 133 |
+
<div className="flex items-center gap-1">
|
| 134 |
+
<button
|
| 135 |
+
type="button"
|
| 136 |
+
onClick={() => {
|
| 137 |
+
setPath(null);
|
| 138 |
+
}}
|
| 139 |
+
className="shrink-0 rounded border border-neutral-700 px-2 py-0.5 text-[10px] text-neutral-300 hover:border-sky-600"
|
| 140 |
+
>
|
| 141 |
+
home
|
| 142 |
+
</button>
|
| 143 |
+
<button
|
| 144 |
+
type="button"
|
| 145 |
+
disabled={!current?.parent}
|
| 146 |
+
onClick={() => {
|
| 147 |
+
if (current?.parent) setPath(current.parent);
|
| 148 |
+
}}
|
| 149 |
+
className="shrink-0 rounded border border-neutral-700 px-2 py-0.5 text-[10px] text-neutral-300 hover:border-sky-600 disabled:opacity-40"
|
| 150 |
+
>
|
| 151 |
+
up
|
| 152 |
+
</button>
|
| 153 |
+
<button
|
| 154 |
+
type="button"
|
| 155 |
+
disabled={current === null || select.isPending}
|
| 156 |
+
onClick={() => {
|
| 157 |
+
if (current !== null) select.mutate(current.path);
|
| 158 |
+
}}
|
| 159 |
+
className="ml-auto shrink-0 rounded border border-sky-700 px-2 py-0.5 text-[10px] font-semibold text-sky-300 hover:bg-sky-950 disabled:opacity-40"
|
| 160 |
+
>
|
| 161 |
+
{select.isPending ? 'selecting…' : isCurrentWorkspace ? 'select' : 'register & select'}
|
| 162 |
+
</button>
|
| 163 |
+
</div>
|
| 164 |
+
<span
|
| 165 |
+
title={current?.path}
|
| 166 |
+
className="truncate font-mono text-[10px] text-neutral-500"
|
| 167 |
+
>
|
| 168 |
+
{current?.path ?? '…'}
|
| 169 |
+
</span>
|
| 170 |
+
</div>
|
| 171 |
+
{recents.length > 0 ? (
|
| 172 |
+
<div className="flex flex-wrap items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
| 173 |
+
<span className="text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
|
| 174 |
+
recent
|
| 175 |
+
</span>
|
| 176 |
+
{recents.map((root) => (
|
| 177 |
+
<button
|
| 178 |
+
key={root}
|
| 179 |
+
type="button"
|
| 180 |
+
title={root}
|
| 181 |
+
onClick={() => {
|
| 182 |
+
setPath(root);
|
| 183 |
+
}}
|
| 184 |
+
className="max-w-32 truncate rounded border border-neutral-800 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 hover:border-sky-600 hover:text-neutral-200"
|
| 185 |
+
>
|
| 186 |
+
{baseName(root)}
|
| 187 |
+
</button>
|
| 188 |
+
))}
|
| 189 |
+
</div>
|
| 190 |
+
) : null}
|
| 191 |
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
| 192 |
+
{browse.isError ? (
|
| 193 |
+
<div className="px-3 py-2">
|
| 194 |
+
<ErrorLine error={browse.error} />
|
| 195 |
+
</div>
|
| 196 |
+
) : current === null ? (
|
| 197 |
+
<div className="px-3 py-2 text-[11px] text-neutral-600 italic">loading…</div>
|
| 198 |
+
) : current.entries.length === 0 ? (
|
| 199 |
+
<div className="px-3 py-2 text-[11px] text-neutral-600 italic">no subdirectories</div>
|
| 200 |
+
) : (
|
| 201 |
+
current.entries.map(renderEntry)
|
| 202 |
+
)}
|
| 203 |
+
</div>
|
| 204 |
+
{select.isError ? (
|
| 205 |
+
<div className="border-t border-neutral-800 px-3 py-1">
|
| 206 |
+
<ErrorLine error={select.error} />
|
| 207 |
+
</div>
|
| 208 |
+
) : null}
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
}
|
apps/kimi-inspect/src/components/WorkspaceServicesView.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Workspace Services view — the workspace-scope Service reflection as a
|
| 3 |
+
* standalone rail view. Same Postman-style three-pane layout
|
| 4 |
+
* (`ScopePanelsScrollspy`) as App Services, plus a left sidebar holding the
|
| 5 |
+
* workspace picker: a server-side directory browser (`WorkspaceDirBrowser`,
|
| 6 |
+
* over the App-scope `IHostFolderBrowser`) that marks already-registered
|
| 7 |
+
* workspaces and registers a picked folder on demand. The proxies resolve on
|
| 8 |
+
* the `/workspace/:id` route, so a workspace must be selected before any
|
| 9 |
+
* Service is callable. Picking one materializes its handler on demand
|
| 10 |
+
* server-side (`IWorkspaceLifecycleService.handlerFor` is create-or-get), no
|
| 11 |
+
* manual join needed.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
|
| 15 |
+
import { useQuery } from '@tanstack/react-query';
|
| 16 |
+
import { useEffect, useState, type ReactNode } from 'react';
|
| 17 |
+
|
| 18 |
+
import { useConnection } from '../connection';
|
| 19 |
+
import { fetchWorkspaceSnapshot } from '../snapshots/api';
|
| 20 |
+
import { Badge, ErrorLine } from '../ui';
|
| 21 |
+
import { WorkspaceDirBrowser } from './WorkspaceDirBrowser';
|
| 22 |
+
|
| 23 |
+
export function WorkspaceServicesView() {
|
| 24 |
+
const { klient, baseUrl } = useConnection();
|
| 25 |
+
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
| 26 |
+
|
| 27 |
+
const workspaces = useQuery({
|
| 28 |
+
queryKey: ['workspaces', klient.baseUrl],
|
| 29 |
+
queryFn: () => klient.core(IWorkspaceService).list(),
|
| 30 |
+
});
|
| 31 |
+
const snapshot = useQuery({
|
| 32 |
+
queryKey: ['workspace-snapshot', klient.baseUrl, workspaceId],
|
| 33 |
+
queryFn: () => fetchWorkspaceSnapshot(klient, workspaceId as string),
|
| 34 |
+
enabled: workspaceId !== null,
|
| 35 |
+
refetchInterval: 1_000,
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
setWorkspaceId(null);
|
| 40 |
+
}, [baseUrl]);
|
| 41 |
+
|
| 42 |
+
const selected = (workspaces.data ?? []).find((ws) => ws.id === workspaceId);
|
| 43 |
+
const data = snapshot.data;
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="flex min-h-0 min-w-0 flex-1">
|
| 47 |
+
<aside className="flex w-72 shrink-0 flex-col border-r border-neutral-800">
|
| 48 |
+
<div className="border-b border-neutral-800 px-3 py-2">
|
| 49 |
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
|
| 50 |
+
Workspace
|
| 51 |
+
</div>
|
| 52 |
+
<div title={selected?.root} className="truncate font-mono text-[11px] text-neutral-300">
|
| 53 |
+
{selected === undefined ? (
|
| 54 |
+
<span className="text-neutral-600 italic">none selected</span>
|
| 55 |
+
) : (
|
| 56 |
+
`${selected.name} — ${selected.root}`
|
| 57 |
+
)}
|
| 58 |
+
</div>
|
| 59 |
+
{workspaces.isError ? <ErrorLine error={workspaces.error} /> : null}
|
| 60 |
+
</div>
|
| 61 |
+
<WorkspaceDirBrowser
|
| 62 |
+
klient={klient}
|
| 63 |
+
workspaces={workspaces.data}
|
| 64 |
+
onSelect={(workspace) => setWorkspaceId(workspace.id)}
|
| 65 |
+
/>
|
| 66 |
+
</aside>
|
| 67 |
+
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4">
|
| 68 |
+
{workspaceId === null ? (
|
| 69 |
+
<div className="flex h-full items-center justify-center text-[12px] text-neutral-600 italic">
|
| 70 |
+
select a workspace to inspect
|
| 71 |
+
</div>
|
| 72 |
+
) : snapshot.isError ? (
|
| 73 |
+
<ErrorLine error={snapshot.error} />
|
| 74 |
+
) : data === undefined ? (
|
| 75 |
+
<div className="text-[12px] text-neutral-600">loading workspace snapshot…</div>
|
| 76 |
+
) : (
|
| 77 |
+
<div className="space-y-4">
|
| 78 |
+
<SnapshotPanel title="Workspace">
|
| 79 |
+
<SnapshotRow label="id" value={data.metadata.id} />
|
| 80 |
+
<SnapshotRow label="name" value={data.metadata.name} />
|
| 81 |
+
<SnapshotRow label="root" value={data.metadata.root} />
|
| 82 |
+
<SnapshotRow label="lifecycle" value={<Badge tone="sky">{data.lifecycle}</Badge>} />
|
| 83 |
+
</SnapshotPanel>
|
| 84 |
+
<SnapshotPanel title="Program">
|
| 85 |
+
<SnapshotRow label="binding" value={`${data.program.binding.workspaceId} / ${data.program.binding.runtimeId}`} />
|
| 86 |
+
<SnapshotRow label="status" value={<Badge tone={data.program.status === 'ready' ? 'green' : 'neutral'}>{data.program.status}</Badge>} />
|
| 87 |
+
<SnapshotRow label="ready" value={String(data.program.ready)} />
|
| 88 |
+
<SnapshotRow label="generation" value={data.program.generation ?? 'unavailable'} />
|
| 89 |
+
<SnapshotRow label="trusted" value={data.program.trusted === undefined ? 'unknown' : String(data.program.trusted)} />
|
| 90 |
+
<SnapshotRow label="skills" value={`${data.program.catalog.skills.total} total / ${data.program.catalog.skills.invocable} invocable / ${data.program.catalog.skills.skipped} skipped`} />
|
| 91 |
+
<SnapshotRow label="agent profiles" value={String(data.program.catalog.agentProfiles)} />
|
| 92 |
+
<SnapshotRow label="MCP servers" value={String(data.program.catalog.mcpServers)} />
|
| 93 |
+
</SnapshotPanel>
|
| 94 |
+
<SnapshotPanel title="Runtimes">
|
| 95 |
+
{data.runtimes.runtimes.length === 0 ? (
|
| 96 |
+
<div className="text-[11px] text-neutral-600">no current generations</div>
|
| 97 |
+
) : data.runtimes.runtimes.map((runtime) => (
|
| 98 |
+
<div key={`${runtime.runtimeId}:${runtime.generation}`} className="rounded border border-neutral-800 bg-neutral-950/40 p-2">
|
| 99 |
+
<div className="mb-1 flex items-center gap-2">
|
| 100 |
+
<span className="font-mono text-[12px] text-neutral-200">{runtime.runtimeId}</span>
|
| 101 |
+
<Badge tone={runtime.status === 'ready' ? 'green' : 'neutral'}>{runtime.status}</Badge>
|
| 102 |
+
</div>
|
| 103 |
+
<SnapshotRow label="generation" value={runtime.generation} />
|
| 104 |
+
<SnapshotRow label="capabilities" value={runtime.capabilities.join(', ') || 'none'} />
|
| 105 |
+
</div>
|
| 106 |
+
))}
|
| 107 |
+
</SnapshotPanel>
|
| 108 |
+
<SnapshotPanel title="Source provenance">
|
| 109 |
+
<SnapshotRow label="skills" value={data.program.sources.skills.map((source) => `${source.source}:${source.count}`).join(', ') || 'none'} />
|
| 110 |
+
<SnapshotRow label="skill roots" value={data.program.sources.skillRoots.join(', ') || 'none'} />
|
| 111 |
+
<SnapshotRow label="agent profiles" value={data.program.sources.agentProfiles.map((source) => `${source.sourceId}:${source.profiles.join('|')}`).join(', ') || 'none'} />
|
| 112 |
+
<SnapshotRow label="instructions" value={data.program.sources.instructionPaths.join(', ') || 'none'} />
|
| 113 |
+
<SnapshotRow label="MCP" value={data.program.sources.mcpServers.join(', ') || 'none'} />
|
| 114 |
+
</SnapshotPanel>
|
| 115 |
+
</div>
|
| 116 |
+
)}
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function SnapshotPanel({ title, children }: { readonly title: string; readonly children: ReactNode }) {
|
| 123 |
+
return (
|
| 124 |
+
<section className="rounded border border-neutral-800 bg-neutral-900/30 p-3">
|
| 125 |
+
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-400">{title}</h2>
|
| 126 |
+
<div className="space-y-1">{children}</div>
|
| 127 |
+
</section>
|
| 128 |
+
);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function SnapshotRow({ label, value }: { readonly label: string; readonly value: ReactNode }) {
|
| 132 |
+
return (
|
| 133 |
+
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2 text-[11px]">
|
| 134 |
+
<span className="text-neutral-600">{label}</span>
|
| 135 |
+
<span className="min-w-0 break-all font-mono text-neutral-300">{value}</span>
|
| 136 |
+
</div>
|
| 137 |
+
);
|
| 138 |
+
}
|
apps/kimi-inspect/src/components/audit/AuditPanel.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Audit panel — the `Audit` tab of the chat view's right dock
|
| 3 |
+
* (`RightPanel`): replays how the visible `ChatStore` was built, entry by
|
| 4 |
+
* entry. It used to be a standalone column docked inside the chat view.
|
| 5 |
+
*
|
| 6 |
+
* - Timeline (draggable slider + entry list): every REST history page,
|
| 7 |
+
* every WS message (entity / delta / state), and every channel event
|
| 8 |
+
* (subscribe ack, reconnect, catch-up fallback, prompt/cancel), with its
|
| 9 |
+
* timestamp.
|
| 10 |
+
* - Detail tabs for the selected entry: `Diff` (structural diff vs the
|
| 11 |
+
* previous entry — added/modified/removed colored), `State` (the full
|
| 12 |
+
* store state at that point: entity timeline plus the interaction /
|
| 13 |
+
* task / todo / session.state entities), `Event` (the raw REST
|
| 14 |
+
* request/response or WS payload).
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
| 18 |
+
|
| 19 |
+
import { diffValue, type DiffNode } from '../../audit/diff';
|
| 20 |
+
import { serializeState } from '../../audit/serialize';
|
| 21 |
+
import type { AuditEntry, AuditTrail } from '../../audit/trail';
|
| 22 |
+
import { tailTrunc } from '../../audit/truncate';
|
| 23 |
+
import { EMPTY_CHAT_STATE } from '../../transcript/store';
|
| 24 |
+
import { Badge } from '../../ui';
|
| 25 |
+
import { plainNode, StateTree } from './StateTree';
|
| 26 |
+
|
| 27 |
+
const KIND_TONE: Record<AuditEntry['kind'], 'sky' | 'green' | 'neutral'> = {
|
| 28 |
+
rest: 'sky',
|
| 29 |
+
ws: 'green',
|
| 30 |
+
event: 'neutral',
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
function fmtTime(iso: string): string {
|
| 34 |
+
const d = new Date(iso);
|
| 35 |
+
const base = d.toLocaleTimeString('en-GB', { hour12: false });
|
| 36 |
+
return `${base}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/** Raw payload view: full JSON, with long strings tail-truncated. */
|
| 40 |
+
function EventJson({ entry }: { entry: AuditEntry }) {
|
| 41 |
+
const payload = useMemo(() => {
|
| 42 |
+
switch (entry.kind) {
|
| 43 |
+
case 'rest':
|
| 44 |
+
return {
|
| 45 |
+
request: entry.request,
|
| 46 |
+
mode: entry.mode,
|
| 47 |
+
messageCount: entry.messageCount,
|
| 48 |
+
inFlight: entry.inFlight,
|
| 49 |
+
};
|
| 50 |
+
case 'ws':
|
| 51 |
+
return entry.message;
|
| 52 |
+
case 'event':
|
| 53 |
+
return { event: entry.event, detail: entry.detail };
|
| 54 |
+
}
|
| 55 |
+
}, [entry]);
|
| 56 |
+
const text = JSON.stringify(
|
| 57 |
+
payload,
|
| 58 |
+
(_key, value: unknown) => (typeof value === 'string' ? tailTrunc(value) : value),
|
| 59 |
+
2,
|
| 60 |
+
);
|
| 61 |
+
return (
|
| 62 |
+
<pre className="p-2 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-neutral-300">
|
| 63 |
+
{text}
|
| 64 |
+
</pre>
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function AuditPanel({ trail }: { trail: AuditTrail }) {
|
| 69 |
+
const entries = useSyncExternalStore(trail.subscribe, () => trail.getEntries());
|
| 70 |
+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
| 71 |
+
const [follow, setFollow] = useState(true);
|
| 72 |
+
const [tab, setTab] = useState<'diff' | 'state' | 'event'>('diff');
|
| 73 |
+
const listRef = useRef<HTMLDivElement>(null);
|
| 74 |
+
|
| 75 |
+
const current: AuditEntry | undefined =
|
| 76 |
+
entries.find((entry) => entry.index === selectedIndex) ?? entries.at(-1);
|
| 77 |
+
const currentPos = current !== undefined ? entries.indexOf(current) : -1;
|
| 78 |
+
|
| 79 |
+
// Follow-latest: jump to (and scroll to) the newest entry as it lands.
|
| 80 |
+
useEffect(() => {
|
| 81 |
+
if (!follow || entries.length === 0) return;
|
| 82 |
+
setSelectedIndex(entries.at(-1)!.index);
|
| 83 |
+
const list = listRef.current;
|
| 84 |
+
if (list !== null) list.scrollTop = list.scrollHeight;
|
| 85 |
+
}, [entries, follow]);
|
| 86 |
+
|
| 87 |
+
const root: DiffNode | null = useMemo(() => {
|
| 88 |
+
if (current === undefined || tab === 'event') return null;
|
| 89 |
+
if (tab === 'state') return plainNode(serializeState(current.state));
|
| 90 |
+
const prevState =
|
| 91 |
+
currentPos > 0 ? (entries[currentPos - 1]?.state ?? EMPTY_CHAT_STATE) : EMPTY_CHAT_STATE;
|
| 92 |
+
return diffValue(serializeState(prevState), serializeState(current.state));
|
| 93 |
+
}, [current, currentPos, entries, tab]);
|
| 94 |
+
|
| 95 |
+
return (
|
| 96 |
+
<div className="flex h-full flex-col">
|
| 97 |
+
<div className="flex items-center gap-2 border-b border-neutral-800 px-3 py-2">
|
| 98 |
+
<span className="text-[12px] font-medium text-neutral-200">Transcript audit</span>
|
| 99 |
+
<Badge tone="neutral">{entries.length} entries</Badge>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div className="flex items-center gap-2 border-b border-neutral-800 px-3 py-1.5">
|
| 103 |
+
<input
|
| 104 |
+
type="range"
|
| 105 |
+
className="min-w-0 flex-1 accent-sky-500"
|
| 106 |
+
min={0}
|
| 107 |
+
max={Math.max(0, entries.length - 1)}
|
| 108 |
+
value={Math.max(0, currentPos)}
|
| 109 |
+
disabled={entries.length === 0}
|
| 110 |
+
onChange={(e) => {
|
| 111 |
+
const pos = Number(e.target.value);
|
| 112 |
+
const entry = entries[pos];
|
| 113 |
+
if (entry === undefined) return;
|
| 114 |
+
setFollow(pos === entries.length - 1);
|
| 115 |
+
setSelectedIndex(entry.index);
|
| 116 |
+
}}
|
| 117 |
+
/>
|
| 118 |
+
<label className="flex items-center gap-1 text-[10px] text-neutral-500 select-none">
|
| 119 |
+
<input
|
| 120 |
+
type="checkbox"
|
| 121 |
+
checked={follow}
|
| 122 |
+
onChange={(e) => setFollow(e.target.checked)}
|
| 123 |
+
className="accent-sky-500"
|
| 124 |
+
/>
|
| 125 |
+
follow
|
| 126 |
+
</label>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div ref={listRef} className="h-56 shrink-0 overflow-y-auto border-b border-neutral-800">
|
| 130 |
+
{entries.length === 0 ? (
|
| 131 |
+
<div className="px-3 py-2 text-[11px] text-neutral-600 italic">
|
| 132 |
+
Nothing recorded yet — the initial history load is still running.
|
| 133 |
+
</div>
|
| 134 |
+
) : null}
|
| 135 |
+
{entries.map((entry, pos) => (
|
| 136 |
+
<div
|
| 137 |
+
key={entry.index}
|
| 138 |
+
className={`flex cursor-pointer items-center gap-2 px-3 py-1 text-[11px] hover:bg-neutral-800/70 ${
|
| 139 |
+
entry.index === current?.index ? 'bg-sky-950/50' : ''
|
| 140 |
+
}`}
|
| 141 |
+
onClick={() => {
|
| 142 |
+
setFollow(pos === entries.length - 1);
|
| 143 |
+
setSelectedIndex(entry.index);
|
| 144 |
+
}}
|
| 145 |
+
>
|
| 146 |
+
<span className="w-8 shrink-0 font-mono text-neutral-600">#{entry.index}</span>
|
| 147 |
+
<span className="shrink-0 font-mono text-neutral-500">{fmtTime(entry.at)}</span>
|
| 148 |
+
<Badge tone={KIND_TONE[entry.kind]}>{entry.kind}</Badge>
|
| 149 |
+
<span className="min-w-0 truncate text-neutral-400" title={entry.summary}>
|
| 150 |
+
{entry.summary}
|
| 151 |
+
</span>
|
| 152 |
+
</div>
|
| 153 |
+
))}
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-1.5">
|
| 157 |
+
{(['diff', 'state', 'event'] as const).map((name) => (
|
| 158 |
+
<button
|
| 159 |
+
key={name}
|
| 160 |
+
className={`rounded px-2 py-0.5 text-[11px] ${
|
| 161 |
+
tab === name
|
| 162 |
+
? 'bg-neutral-800 text-neutral-100'
|
| 163 |
+
: 'text-neutral-500 hover:text-neutral-300'
|
| 164 |
+
}`}
|
| 165 |
+
onClick={() => setTab(name)}
|
| 166 |
+
>
|
| 167 |
+
{name === 'diff' ? 'Diff vs prev' : name === 'state' ? 'State' : 'Event'}
|
| 168 |
+
</button>
|
| 169 |
+
))}
|
| 170 |
+
{current !== undefined ? (
|
| 171 |
+
<span className="ml-auto font-mono text-[10px] text-neutral-600">
|
| 172 |
+
entry #{current.index}
|
| 173 |
+
</span>
|
| 174 |
+
) : null}
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div className="min-h-0 flex-1 overflow-y-auto px-2">
|
| 178 |
+
{current === undefined ? (
|
| 179 |
+
<div className="px-1 py-2 text-[11px] text-neutral-600 italic">No entry selected.</div>
|
| 180 |
+
) : tab === 'event' ? (
|
| 181 |
+
<EventJson entry={current} />
|
| 182 |
+
) : root !== null ? (
|
| 183 |
+
<StateTree root={root} defaultDepth={tab === 'state' ? 2 : 0} />
|
| 184 |
+
) : null}
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
);
|
| 188 |
+
}
|
apps/kimi-inspect/src/components/audit/StateTree.test.tsx
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* StateTree rendering tests (SSR via renderToStaticMarkup — no DOM needed).
|
| 3 |
+
* Locks in two audit-panel readability rules:
|
| 4 |
+
* 1. Unchanged subtrees collapse into `{ …N }` rows — never a one-line
|
| 5 |
+
* compact-JSON dump (the copy-on-write fast path gives them no diff
|
| 6 |
+
* children, and rendering the raw value destroyed the layout).
|
| 7 |
+
* 2. Whole-subtree adds expand into fully fielded, indented tree rows.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import type { AssistantMessage, StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol';
|
| 11 |
+
import { renderToStaticMarkup } from 'react-dom/server';
|
| 12 |
+
import { describe, expect, it } from 'vitest';
|
| 13 |
+
|
| 14 |
+
import { diffValue } from '../../audit/diff';
|
| 15 |
+
import { serializeState } from '../../audit/serialize';
|
| 16 |
+
import { EMPTY_CHAT_STATE, type ChatState } from '../../transcript/store';
|
| 17 |
+
import { plainNode, StateTree } from './StateTree';
|
| 18 |
+
|
| 19 |
+
const T0 = Date.parse('2026-01-01T00:00:00.000Z');
|
| 20 |
+
let tick = 0;
|
| 21 |
+
|
| 22 |
+
function ts(): number {
|
| 23 |
+
tick += 1;
|
| 24 |
+
return T0 + tick * 1000;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function turnMsg(n: number, text?: string): TurnMessage {
|
| 28 |
+
return {
|
| 29 |
+
type: 'turn',
|
| 30 |
+
session_id: 's1',
|
| 31 |
+
agent_id: 'main',
|
| 32 |
+
timestamp: ts(),
|
| 33 |
+
turn_id: `t${n}`,
|
| 34 |
+
ordinal: n,
|
| 35 |
+
status: 'completed',
|
| 36 |
+
origin: { kind: 'user' },
|
| 37 |
+
user_message_id: text,
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function stepMsg(stepId: string): StepMessage {
|
| 42 |
+
return {
|
| 43 |
+
type: 'step',
|
| 44 |
+
session_id: 's1',
|
| 45 |
+
agent_id: 'main',
|
| 46 |
+
timestamp: ts(),
|
| 47 |
+
step_id: stepId,
|
| 48 |
+
turn_id: stepId.split('.')[0] ?? 't0',
|
| 49 |
+
ordinal: Number(stepId.split('.')[1] ?? '1'),
|
| 50 |
+
status: 'running',
|
| 51 |
+
};
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function assistantMsg(stepId: string, text: string): AssistantMessage {
|
| 55 |
+
return {
|
| 56 |
+
type: 'assistant',
|
| 57 |
+
session_id: 's1',
|
| 58 |
+
agent_id: 'main',
|
| 59 |
+
timestamp: ts(),
|
| 60 |
+
message_id: `${stepId}.a0`,
|
| 61 |
+
turn_id: stepId.split('.')[0] ?? 't0',
|
| 62 |
+
step_id: stepId,
|
| 63 |
+
status: 'streaming',
|
| 64 |
+
text,
|
| 65 |
+
};
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
type FlatMessage = TurnMessage | StepMessage | AssistantMessage;
|
| 69 |
+
|
| 70 |
+
function stateWithTimeline(items: readonly FlatMessage[]): ChatState {
|
| 71 |
+
return {
|
| 72 |
+
...EMPTY_CHAT_STATE,
|
| 73 |
+
entries: items.map((message) => ({
|
| 74 |
+
key:
|
| 75 |
+
message.type === 'turn'
|
| 76 |
+
? `turn:${message.turn_id}`
|
| 77 |
+
: message.type === 'step'
|
| 78 |
+
? `step:${message.step_id}`
|
| 79 |
+
: `assistant:${message.message_id}`,
|
| 80 |
+
message,
|
| 81 |
+
})),
|
| 82 |
+
};
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
describe('StateTree', () => {
|
| 86 |
+
it('collapses unchanged subtrees instead of dumping compact JSON', () => {
|
| 87 |
+
const t0 = turnMsg(0, 'PROMPT_ZERO');
|
| 88 |
+
const prev = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE')]);
|
| 89 |
+
const next: ChatState = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE_V2')]);
|
| 90 |
+
const html = renderToStaticMarkup(
|
| 91 |
+
<StateTree root={diffValue(serializeState(prev), serializeState(next))} />,
|
| 92 |
+
);
|
| 93 |
+
// No one-line JSON blob anywhere.
|
| 94 |
+
expect(html).not.toContain('{"type"');
|
| 95 |
+
// The unchanged turn t0 stays folded: its marker is not rendered…
|
| 96 |
+
expect(html).not.toContain('PROMPT_ZERO');
|
| 97 |
+
// …while the modified turn opens and shows old → new.
|
| 98 |
+
expect(html).toContain('PROMPT_ONE_V2');
|
| 99 |
+
expect(html).toContain('PROMPT_ONE');
|
| 100 |
+
expect(html).toContain('→');
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
it('expands whole-subtree adds into full field rows (all keys, no JSON dump)', () => {
|
| 104 |
+
const root = diffValue(
|
| 105 |
+
serializeState(EMPTY_CHAT_STATE),
|
| 106 |
+
serializeState(stateWithTimeline([turnMsg(0, 'HELLO')])),
|
| 107 |
+
);
|
| 108 |
+
const html = renderToStaticMarkup(<StateTree root={root} />);
|
| 109 |
+
expect(html).not.toContain('{"type"');
|
| 110 |
+
for (const field of ['turn_id', 'ordinal', 'status', 'origin', 'timestamp', 'agent_id']) {
|
| 111 |
+
expect(html).toContain(field);
|
| 112 |
+
}
|
| 113 |
+
expect(html).toContain('HELLO');
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
it('expands added subtrees with id-based keys and renders closing braces', () => {
|
| 117 |
+
const html = renderToStaticMarkup(
|
| 118 |
+
<StateTree
|
| 119 |
+
root={diffValue(
|
| 120 |
+
serializeState(EMPTY_CHAT_STATE),
|
| 121 |
+
serializeState(
|
| 122 |
+
stateWithTimeline([turnMsg(0), stepMsg('t0.1'), assistantMsg('t0.1', 'hmm')]),
|
| 123 |
+
),
|
| 124 |
+
)}
|
| 125 |
+
/>,
|
| 126 |
+
);
|
| 127 |
+
// Array children are keyed by their ids, not #indices.
|
| 128 |
+
expect(html).toContain('t0.1');
|
| 129 |
+
expect(html).toContain('t0.1.a0');
|
| 130 |
+
expect(html).not.toContain('#0');
|
| 131 |
+
// Open containers end with an explicit closing brace row.
|
| 132 |
+
expect(html).toContain(']');
|
| 133 |
+
expect(html).toContain('}');
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
it('plain state mode opens to defaultDepth and shows all top-level fields', () => {
|
| 137 |
+
const html = renderToStaticMarkup(
|
| 138 |
+
<StateTree
|
| 139 |
+
root={plainNode(serializeState(stateWithTimeline([turnMsg(0)])))}
|
| 140 |
+
defaultDepth={2}
|
| 141 |
+
/>,
|
| 142 |
+
);
|
| 143 |
+
for (const field of ['timeline', 'interactions', 'tasks', 'todos', 'hasMoreOlder']) {
|
| 144 |
+
expect(html).toContain(field);
|
| 145 |
+
}
|
| 146 |
+
expect(html).not.toContain('{"type"');
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
it('collapses multiline strings into a hover-preview button', () => {
|
| 150 |
+
const root = plainNode({ note: 'line one\nline two\nline three', single: 'one-liner' });
|
| 151 |
+
const html = renderToStaticMarkup(<StateTree root={root} defaultDepth={1} />);
|
| 152 |
+
// The multiline value renders as a compact button: first-line preview +
|
| 153 |
+
// line count, with the remaining lines NOT inlined into the tree (they
|
| 154 |
+
// only appear in the hover panel, which needs a real mouse to open).
|
| 155 |
+
expect(html).toContain('"line one" ⏎ 3');
|
| 156 |
+
expect(html).not.toContain('line two');
|
| 157 |
+
expect(html).not.toContain('line three');
|
| 158 |
+
// Single-line strings keep the inline rendering.
|
| 159 |
+
expect(html).toContain('"one-liner"');
|
| 160 |
+
});
|
| 161 |
+
});
|
apps/kimi-inspect/src/components/audit/StateTree.tsx
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Diff-aware state tree for the audit panel.
|
| 3 |
+
*
|
| 4 |
+
* Renders a serialized `ChatState` (see `audit/serialize.ts`) as a
|
| 5 |
+
* collapsible tree, colored by the structural diff against the previous
|
| 6 |
+
* trail entry: added = green, removed = red + strikethrough, modified =
|
| 7 |
+
* amber (`old → new` on leaves). Every field is rendered — long strings
|
| 8 |
+
* are tail-truncated (`tailTrunc`) but no key is ever dropped, and a string
|
| 9 |
+
* containing newlines collapses into one compact button (first-line preview
|
| 10 |
+
* + line count) whose hover pops a `fixed` full-text panel at the button's
|
| 11 |
+
* right edge.
|
| 12 |
+
*
|
| 13 |
+
* The tree is driven entirely by a `DiffNode` root: diff mode passes
|
| 14 |
+
* `diffValue(prev, next)`, plain state mode passes `plainNode(value)`.
|
| 15 |
+
* Diff nodes that carry an object/array but no children (whole-subtree
|
| 16 |
+
* added/removed, or reference-equal unchanged subtrees collapsed by the
|
| 17 |
+
* diff fast path) are expanded into same-status blocks at render time
|
| 18 |
+
* (`blockNode`) — they show as collapsible `{ …N }` rows instead of an
|
| 19 |
+
* unreadable one-line JSON dump.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
import { useEffect, useRef, useState } from 'react';
|
| 23 |
+
|
| 24 |
+
import { elementId, type DiffNode, type DiffStatus } from '../../audit/diff';
|
| 25 |
+
import { tailTrunc } from '../../audit/truncate';
|
| 26 |
+
|
| 27 |
+
/** Build an all-`unchanged` tree over a plain value (plain state mode). */
|
| 28 |
+
export function plainNode(value: unknown): DiffNode {
|
| 29 |
+
return blockNode(value, 'unchanged');
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function blockNode(value: unknown, status: DiffStatus): DiffNode {
|
| 33 |
+
const current = status === 'removed' ? undefined : value;
|
| 34 |
+
const prev = status === 'removed' ? value : undefined;
|
| 35 |
+
if (typeof value === 'object' && value !== null) {
|
| 36 |
+
const children = new Map<string, DiffNode>();
|
| 37 |
+
const entries: Array<readonly [string, unknown]> = Array.isArray(value)
|
| 38 |
+
? value.map((item, index) => [elementId(item) ?? `#${index}`, item] as const)
|
| 39 |
+
: Object.entries(value as Record<string, unknown>).filter(([, item]) => item !== undefined);
|
| 40 |
+
for (const [key, item] of entries) children.set(key, blockNode(item, status));
|
| 41 |
+
return { status, value: current, prev, children };
|
| 42 |
+
}
|
| 43 |
+
return { status, value: current, prev };
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function subtreeHasChanges(node: DiffNode): boolean {
|
| 47 |
+
if (node.status !== 'unchanged') return true;
|
| 48 |
+
if (node.children === undefined) return false;
|
| 49 |
+
for (const child of node.children.values()) {
|
| 50 |
+
if (subtreeHasChanges(child)) return true;
|
| 51 |
+
}
|
| 52 |
+
return false;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const ROW_TONE: Record<DiffStatus, string> = {
|
| 56 |
+
unchanged: 'border-transparent',
|
| 57 |
+
added: 'border-green-800 bg-green-950/40',
|
| 58 |
+
removed: 'border-red-800 bg-red-950/40',
|
| 59 |
+
modified: 'border-amber-700 bg-amber-950/30',
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const TEXT_TONE: Record<DiffStatus, string> = {
|
| 63 |
+
unchanged: 'text-neutral-400',
|
| 64 |
+
added: 'text-green-400',
|
| 65 |
+
removed: 'text-red-400 line-through',
|
| 66 |
+
modified: 'text-amber-300',
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
function Leaf({ value, tone }: { value: unknown; tone: DiffStatus }) {
|
| 70 |
+
const className = TEXT_TONE[tone];
|
| 71 |
+
if (value === null) return <span className={className}>null</span>;
|
| 72 |
+
if (value === undefined) return <span className={className}>undefined</span>;
|
| 73 |
+
if (typeof value === 'string') {
|
| 74 |
+
if (value.includes('\n')) return <MultilineString value={value} tone={tone} />;
|
| 75 |
+
return (
|
| 76 |
+
<span className={`${className} whitespace-pre-wrap break-all`}>"{tailTrunc(value)}"</span>
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
| 80 |
+
return <span className={className}>{String(value)}</span>;
|
| 81 |
+
}
|
| 82 |
+
return <span className={className}>{JSON.stringify(value) ?? 'unknown'}</span>;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Multiline string leaf: rendered as one compact button (first-line preview
|
| 87 |
+
* + line count) so a prompt or tool output never stretches the tree. Hovering
|
| 88 |
+
* the button pops a `fixed` panel at its right edge with the full text —
|
| 89 |
+
* `fixed` because the tree sits inside an `overflow-y-auto` column that
|
| 90 |
+
* would clip an absolutely-positioned popup. A 150 ms close delay lets the
|
| 91 |
+
* pointer travel from the button into the panel (to scroll or select).
|
| 92 |
+
* `popupStyle` keeps the whole panel inside the viewport: the total height
|
| 93 |
+
* is capped by the space left below the clamped top edge, and the panel
|
| 94 |
+
* flips to the button's left side when the remaining right space is cramped.
|
| 95 |
+
*/
|
| 96 |
+
function popupStyle(rect: DOMRect): React.CSSProperties {
|
| 97 |
+
const margin = 8;
|
| 98 |
+
const vh = window.innerHeight;
|
| 99 |
+
const vw = window.innerWidth;
|
| 100 |
+
const maxHeight = Math.max(160, Math.floor(vh * 0.7));
|
| 101 |
+
const top = Math.max(margin, Math.min(rect.top, vh - maxHeight - margin));
|
| 102 |
+
const rightSpace = vw - rect.right - margin * 2;
|
| 103 |
+
if (rightSpace >= 320) {
|
| 104 |
+
return { left: rect.right + margin, top, maxHeight, width: Math.min(560, rightSpace) };
|
| 105 |
+
}
|
| 106 |
+
const leftSpace = rect.left - margin * 2;
|
| 107 |
+
return {
|
| 108 |
+
right: vw - rect.left + margin,
|
| 109 |
+
top,
|
| 110 |
+
maxHeight,
|
| 111 |
+
width: Math.min(560, Math.max(280, leftSpace)),
|
| 112 |
+
};
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function MultilineString({ value, tone }: { value: string; tone: DiffStatus }) {
|
| 116 |
+
const [rect, setRect] = useState<DOMRect | null>(null);
|
| 117 |
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
| 118 |
+
useEffect(() => () => clearTimeout(closeTimer.current), []);
|
| 119 |
+
|
| 120 |
+
const lines = value.split('\n');
|
| 121 |
+
const firstLine = lines[0] ?? '';
|
| 122 |
+
const preview = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
|
| 123 |
+
|
| 124 |
+
const open = (e: React.MouseEvent<HTMLButtonElement>) => {
|
| 125 |
+
clearTimeout(closeTimer.current);
|
| 126 |
+
setRect(e.currentTarget.getBoundingClientRect());
|
| 127 |
+
};
|
| 128 |
+
const scheduleClose = () => {
|
| 129 |
+
clearTimeout(closeTimer.current);
|
| 130 |
+
closeTimer.current = setTimeout(() => setRect(null), 150);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
return (
|
| 134 |
+
<>
|
| 135 |
+
<button
|
| 136 |
+
type="button"
|
| 137 |
+
className={`${TEXT_TONE[tone]} cursor-pointer rounded border border-neutral-700 bg-neutral-900 px-1 hover:border-sky-600`}
|
| 138 |
+
onMouseEnter={open}
|
| 139 |
+
onMouseLeave={scheduleClose}
|
| 140 |
+
>
|
| 141 |
+
{`"${preview}" ⏎ ${lines.length}`}
|
| 142 |
+
</button>
|
| 143 |
+
{rect !== null ? (
|
| 144 |
+
<div
|
| 145 |
+
className="fixed z-50 flex flex-col rounded border border-neutral-700 bg-neutral-950 shadow-xl"
|
| 146 |
+
style={popupStyle(rect)}
|
| 147 |
+
onMouseEnter={() => clearTimeout(closeTimer.current)}
|
| 148 |
+
onMouseLeave={scheduleClose}
|
| 149 |
+
>
|
| 150 |
+
<div className="shrink-0 border-b border-neutral-800 px-3 py-1 text-[10px] text-neutral-500">
|
| 151 |
+
{lines.length} lines · {value.length} chars
|
| 152 |
+
</div>
|
| 153 |
+
<pre className="min-h-0 flex-1 overflow-auto px-3 py-2 font-mono text-[11px] whitespace-pre-wrap break-all text-neutral-200">
|
| 154 |
+
{tailTrunc(value, 5000)}
|
| 155 |
+
</pre>
|
| 156 |
+
</div>
|
| 157 |
+
) : null}
|
| 158 |
+
</>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function Node({
|
| 163 |
+
name,
|
| 164 |
+
node,
|
| 165 |
+
depth,
|
| 166 |
+
defaultDepth,
|
| 167 |
+
}: {
|
| 168 |
+
name: string;
|
| 169 |
+
node: DiffNode;
|
| 170 |
+
depth: number;
|
| 171 |
+
defaultDepth: number;
|
| 172 |
+
}) {
|
| 173 |
+
// Childless object/array nodes (whole-subtree added/removed, or
|
| 174 |
+
// reference-equal unchanged subtrees) are expanded into same-status
|
| 175 |
+
// blocks so every field stays visible — never a one-line JSON dump.
|
| 176 |
+
const raw = node.status === 'removed' ? node.prev : node.value;
|
| 177 |
+
const effective =
|
| 178 |
+
node.children === undefined && typeof raw === 'object' && raw !== null
|
| 179 |
+
? blockNode(raw, node.status)
|
| 180 |
+
: node;
|
| 181 |
+
|
| 182 |
+
const [open, setOpen] = useState(() => subtreeHasChanges(effective) || depth < defaultDepth);
|
| 183 |
+
const label = <span className={TEXT_TONE[effective.status]}>{name}: </span>;
|
| 184 |
+
|
| 185 |
+
if (effective.children === undefined) {
|
| 186 |
+
return (
|
| 187 |
+
<div
|
| 188 |
+
className={`border-l-2 px-1 font-mono text-[11px] leading-[1.7] ${ROW_TONE[effective.status]}`}
|
| 189 |
+
style={{ paddingLeft: `${depth * 14 + 4}px` }}
|
| 190 |
+
>
|
| 191 |
+
{label}
|
| 192 |
+
{effective.status === 'modified' ? (
|
| 193 |
+
<>
|
| 194 |
+
<Leaf value={effective.prev} tone="removed" />
|
| 195 |
+
<span className="text-neutral-600"> → </span>
|
| 196 |
+
<Leaf value={effective.value} tone="added" />
|
| 197 |
+
</>
|
| 198 |
+
) : (
|
| 199 |
+
<Leaf value={raw} tone={effective.status} />
|
| 200 |
+
)}
|
| 201 |
+
</div>
|
| 202 |
+
);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
const isArray = Array.isArray(raw);
|
| 206 |
+
const [openBrace, closeBrace] = isArray ? ['[', ']'] : ['{', '}'];
|
| 207 |
+
return (
|
| 208 |
+
<div>
|
| 209 |
+
<div
|
| 210 |
+
className={`cursor-pointer select-none border-l-2 px-1 font-mono text-[11px] leading-[1.7] hover:bg-neutral-800/70 ${ROW_TONE[effective.status]}`}
|
| 211 |
+
style={{ paddingLeft: `${depth * 14 + 4}px` }}
|
| 212 |
+
onClick={() => setOpen((v) => !v)}
|
| 213 |
+
>
|
| 214 |
+
<span className="text-neutral-600">{open ? '▾ ' : '▸ '}</span>
|
| 215 |
+
{label}
|
| 216 |
+
<span className="text-neutral-600">
|
| 217 |
+
{open ? openBrace : `${openBrace} …${effective.children.size} ${closeBrace}`}
|
| 218 |
+
</span>
|
| 219 |
+
</div>
|
| 220 |
+
{open ? (
|
| 221 |
+
<>
|
| 222 |
+
{[...effective.children.entries()].map(([key, child]) => (
|
| 223 |
+
<Node key={key} name={key} node={child} depth={depth + 1} defaultDepth={defaultDepth} />
|
| 224 |
+
))}
|
| 225 |
+
<div
|
| 226 |
+
className={`border-l-2 px-1 font-mono text-[11px] leading-[1.7] ${ROW_TONE[effective.status]}`}
|
| 227 |
+
style={{ paddingLeft: `${depth * 14 + 4}px` }}
|
| 228 |
+
>
|
| 229 |
+
<span className="text-neutral-600">{closeBrace}</span>
|
| 230 |
+
</div>
|
| 231 |
+
</>
|
| 232 |
+
) : null}
|
| 233 |
+
</div>
|
| 234 |
+
);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
export function StateTree({
|
| 238 |
+
root,
|
| 239 |
+
defaultDepth = 0,
|
| 240 |
+
}: {
|
| 241 |
+
root: DiffNode;
|
| 242 |
+
/** Unchanged levels opened by default (diff mode 0 = only changes auto-open). */
|
| 243 |
+
defaultDepth?: number;
|
| 244 |
+
}) {
|
| 245 |
+
if (root.children === undefined) {
|
| 246 |
+
return (
|
| 247 |
+
<div className="py-1">
|
| 248 |
+
<Node name="state" node={root} depth={0} defaultDepth={defaultDepth} />
|
| 249 |
+
</div>
|
| 250 |
+
);
|
| 251 |
+
}
|
| 252 |
+
return (
|
| 253 |
+
<div className="py-1">
|
| 254 |
+
{[...root.children.entries()].map(([key, child]) => (
|
| 255 |
+
<Node key={key} name={key} node={child} depth={0} defaultDepth={defaultDepth} />
|
| 256 |
+
))}
|
| 257 |
+
</div>
|
| 258 |
+
);
|
| 259 |
+
}
|
apps/kimi-inspect/src/components/di/DiEventsPanel.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* DI Events panel — event-subscription introspection
|
| 3 |
+
* (`IDebugEventsService.subscriptions`), two merged sides:
|
| 4 |
+
*
|
| 5 |
+
* - Subscriptions: the unit-book side — every materialized unit's ledger
|
| 6 |
+
* entries labeled as an event subscription (`on:<name>` from a named
|
| 7 |
+
* Emitter or the fiber `on` capability, `disposable:EventSubscription`
|
| 8 |
+
* from an unnamed one), grouped by scope path;
|
| 9 |
+
* - Bus listeners: the emitter-side fallback — per-`IEventBus` listener
|
| 10 |
+
* counts (`*` = the full stream) plus the global `IEventService` count,
|
| 11 |
+
* which also cover subscriptions never registered on a unit book.
|
| 12 |
+
*
|
| 13 |
+
* Pure React + Tailwind.
|
| 14 |
+
*/
|
| 15 |
+
import type {
|
| 16 |
+
DebugEventBusSnapshot,
|
| 17 |
+
DebugEventSubscription,
|
| 18 |
+
DebugEventSubscriptions,
|
| 19 |
+
} from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents';
|
| 20 |
+
|
| 21 |
+
import { Badge } from '../../ui';
|
| 22 |
+
|
| 23 |
+
const KIND_TONES: Record<DebugEventSubscription['kind'], 'neutral' | 'sky' | 'violet'> = {
|
| 24 |
+
disposer: 'neutral',
|
| 25 |
+
effect: 'sky',
|
| 26 |
+
ledger: 'violet',
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export function DiEventsPanel({ data }: { data: DebugEventSubscriptions }) {
|
| 30 |
+
const groups = groupByScope(data.subscriptions);
|
| 31 |
+
return (
|
| 32 |
+
<div>
|
| 33 |
+
<div className="mb-1 text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
|
| 34 |
+
subscriptions ({data.subscriptions.length})
|
| 35 |
+
</div>
|
| 36 |
+
{groups.length === 0 ? (
|
| 37 |
+
<div className="mb-4 text-[11px] text-neutral-600 italic">
|
| 38 |
+
no event subscriptions on any unit book
|
| 39 |
+
</div>
|
| 40 |
+
) : (
|
| 41 |
+
groups.map(([scopePath, subs]) => (
|
| 42 |
+
<div
|
| 43 |
+
key={scopePath}
|
| 44 |
+
className="mb-2 rounded-lg border border-neutral-800 bg-neutral-900/60"
|
| 45 |
+
>
|
| 46 |
+
<div className="border-b border-neutral-800/60 px-3 py-2">
|
| 47 |
+
<span className="font-mono text-[11px] text-neutral-200">{scopePath}</span>
|
| 48 |
+
<span className="ml-2 text-[10px] text-neutral-600">{subs.length}</span>
|
| 49 |
+
</div>
|
| 50 |
+
<div className="px-3 py-2">
|
| 51 |
+
{subs.map((sub, i) => (
|
| 52 |
+
<div
|
| 53 |
+
key={`${sub.unit}:${sub.label}:${i}`}
|
| 54 |
+
className="mb-1 flex items-center gap-2 rounded border border-neutral-800/70 bg-neutral-950/40 px-2 py-1.5"
|
| 55 |
+
>
|
| 56 |
+
<span
|
| 57 |
+
className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
|
| 58 |
+
title={sub.unit}
|
| 59 |
+
>
|
| 60 |
+
{sub.unit}
|
| 61 |
+
</span>
|
| 62 |
+
{sub.uid !== undefined ? (
|
| 63 |
+
<span className="shrink-0 text-[10px] text-neutral-600">#{sub.uid}</span>
|
| 64 |
+
) : null}
|
| 65 |
+
<span
|
| 66 |
+
className="shrink-0 font-mono text-[10px] text-sky-400"
|
| 67 |
+
title={sub.label}
|
| 68 |
+
>
|
| 69 |
+
{sub.label}
|
| 70 |
+
</span>
|
| 71 |
+
<span className="ml-auto shrink-0">
|
| 72 |
+
<Badge tone={KIND_TONES[sub.kind]}>{sub.kind}</Badge>
|
| 73 |
+
</span>
|
| 74 |
+
</div>
|
| 75 |
+
))}
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
))
|
| 79 |
+
)}
|
| 80 |
+
<div className="mt-4 mb-1 text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
|
| 81 |
+
bus listeners
|
| 82 |
+
</div>
|
| 83 |
+
{data.buses.length === 0 && data.globalListeners === undefined ? (
|
| 84 |
+
<div className="text-[11px] text-neutral-600 italic">no materialized event buses</div>
|
| 85 |
+
) : (
|
| 86 |
+
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 px-3 py-2">
|
| 87 |
+
{data.globalListeners !== undefined ? (
|
| 88 |
+
<BusRow scopePath="app" type="eventService (global)" count={data.globalListeners} />
|
| 89 |
+
) : null}
|
| 90 |
+
{data.buses.flatMap((bus) => busRows(bus))}
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function groupByScope(
|
| 98 |
+
subs: readonly DebugEventSubscription[],
|
| 99 |
+
): [string, DebugEventSubscription[]][] {
|
| 100 |
+
const map = new Map<string, DebugEventSubscription[]>();
|
| 101 |
+
for (const sub of subs) {
|
| 102 |
+
const group = map.get(sub.scopePath) ?? [];
|
| 103 |
+
group.push(sub);
|
| 104 |
+
map.set(sub.scopePath, group);
|
| 105 |
+
}
|
| 106 |
+
return [...map.entries()];
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function busRows(bus: DebugEventBusSnapshot) {
|
| 110 |
+
const rows = [
|
| 111 |
+
<BusRow key={`${bus.scopePath}:*`} scopePath={bus.scopePath} type="*" count={bus.all} />,
|
| 112 |
+
];
|
| 113 |
+
for (const type of Object.keys(bus.perType).toSorted()) {
|
| 114 |
+
rows.push(
|
| 115 |
+
<BusRow
|
| 116 |
+
key={`${bus.scopePath}:${type}`}
|
| 117 |
+
scopePath={bus.scopePath}
|
| 118 |
+
type={type}
|
| 119 |
+
count={bus.perType[type] ?? 0}
|
| 120 |
+
/>,
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
for (const agentId of Object.keys(bus.perAgent).toSorted()) {
|
| 124 |
+
const count = bus.perAgent[agentId] ?? 0;
|
| 125 |
+
rows.push(
|
| 126 |
+
<BusRow
|
| 127 |
+
key={`${bus.scopePath}:agent:${agentId}`}
|
| 128 |
+
scopePath={bus.scopePath}
|
| 129 |
+
type={`agent:${agentId}`}
|
| 130 |
+
count={count}
|
| 131 |
+
/>,
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
return rows;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function BusRow({
|
| 138 |
+
scopePath,
|
| 139 |
+
type,
|
| 140 |
+
count,
|
| 141 |
+
}: {
|
| 142 |
+
scopePath: string;
|
| 143 |
+
type: string;
|
| 144 |
+
count: number;
|
| 145 |
+
}) {
|
| 146 |
+
return (
|
| 147 |
+
<div className="flex items-center gap-2 py-0.5">
|
| 148 |
+
<span className="min-w-0 truncate font-mono text-[10px] text-neutral-500" title={scopePath}>
|
| 149 |
+
{scopePath}
|
| 150 |
+
</span>
|
| 151 |
+
<span className="shrink-0 font-mono text-[11px] text-neutral-200" title={type}>
|
| 152 |
+
{type}
|
| 153 |
+
</span>
|
| 154 |
+
<span className="ml-auto shrink-0 font-mono text-[11px] text-neutral-400">{count}</span>
|
| 155 |
+
</div>
|
| 156 |
+
);
|
| 157 |
+
}
|
apps/kimi-inspect/src/components/di/DiGraphPanel.tsx
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* DI Deps panel — the persistent dependency DAG (`IDebugGraphService.graph`)
|
| 3 |
+
* as Finder-style Miller columns with path-scoped relation markers. Edges
|
| 4 |
+
* point consumer → dependency, so "X depends on Y" ⟺ an edge
|
| 5 |
+
* `from: X → to: Y`:
|
| 6 |
+
*
|
| 7 |
+
* - column 0 lists the roots: services that never appear as an edge's `to`
|
| 8 |
+
* (nothing consumes them, isolated nodes included), token-sorted,
|
| 9 |
+
* constrained by the scope filter; each root row carries the level-0
|
| 10 |
+
* color bar;
|
| 11 |
+
* - clicking a row in column N truncates the expansion path there and
|
| 12 |
+
* opens column N+1 with that service's direct dependencies (along `to`,
|
| 13 |
+
* unfiltered — cross-scope rows get a scopePath subtitle);
|
| 14 |
+
* - relation bars are path-scoped: a row's left edge carries one 3px bar
|
| 15 |
+
* per path ancestor that depends on it DIRECTLY (edge ancestor → node),
|
| 16 |
+
* ordered by ancestor depth — the direct parent always contributes one,
|
| 17 |
+
* and a grandparent with a skip-level edge adds another. Every color is
|
| 18 |
+
* the fixed per-LEVEL identity color (`levelColor(depth)`), so a bar
|
| 19 |
+
* reads directly as "depended on by the service N levels up", identical
|
| 20 |
+
* in every path; column headers carry the parent level's color chip so
|
| 21 |
+
* bars map back to ancestors;
|
| 22 |
+
* - path highlight: rows covered by the closure (reachable along `to`) of
|
| 23 |
+
* the path's root get a faint background in the level-0 color;
|
| 24 |
+
* - rows without dependencies are dimmed and carry no chevron; a row
|
| 25 |
+
* already in the current path is marked ↩ (cycle guard — columns only
|
| 26 |
+
* ever open on click, so there is no auto-recursion).
|
| 27 |
+
*
|
| 28 |
+
* Pure React + Tailwind, no graph or layout library.
|
| 29 |
+
*/
|
| 30 |
+
import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine';
|
| 31 |
+
import type { DebugGraph, DebugGraphNode } from '@moonshot-ai/agent-core-v2/debug/debugGraph';
|
| 32 |
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 33 |
+
|
| 34 |
+
const STATE_COLORS: Record<UnitState | 'none', string> = {
|
| 35 |
+
Pending: '#a3a3a3',
|
| 36 |
+
Activating: '#38bdf8',
|
| 37 |
+
Active: '#34d399',
|
| 38 |
+
Unloading: '#fbbf24',
|
| 39 |
+
Failed: '#f87171',
|
| 40 |
+
none: '#525252',
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Fixed identity color per path depth (level): `levelColor(d)` is the same
|
| 45 |
+
* for every service at depth d in every path, so a row's color bars read
|
| 46 |
+
* directly as "depended on by the service at level 0 / 1 / 2 …".
|
| 47 |
+
*/
|
| 48 |
+
const LEVEL_PALETTE = [
|
| 49 |
+
'#f87171',
|
| 50 |
+
'#fb923c',
|
| 51 |
+
'#fbbf24',
|
| 52 |
+
'#a3e635',
|
| 53 |
+
'#34d399',
|
| 54 |
+
'#22d3ee',
|
| 55 |
+
'#38bdf8',
|
| 56 |
+
'#818cf8',
|
| 57 |
+
'#c084fc',
|
| 58 |
+
'#f472b6',
|
| 59 |
+
];
|
| 60 |
+
|
| 61 |
+
function levelColor(depth: number): string {
|
| 62 |
+
return LEVEL_PALETTE[depth % LEVEL_PALETTE.length] ?? '#525252';
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function scopeDepth(path: string): number {
|
| 66 |
+
return path.split('/').length;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function compareScopePaths(a: string, b: string): number {
|
| 70 |
+
return scopeDepth(a) - scopeDepth(b) || a.localeCompare(b);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function byToken(a: DebugGraphNode, b: DebugGraphNode): number {
|
| 74 |
+
return a.token.localeCompare(b.token);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function fallbackNode(id: string): DebugGraphNode {
|
| 78 |
+
return { id, token: id, scopePath: '' };
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
interface DepsColumn {
|
| 82 |
+
readonly title: string;
|
| 83 |
+
/**
|
| 84 |
+
* The scope the column's rows belong to by default ('' = unfiltered root
|
| 85 |
+
* column): rows from any other scope get a scopePath subtitle, as do all
|
| 86 |
+
* rows when the column spans more than one scope.
|
| 87 |
+
*/
|
| 88 |
+
readonly homeScope: string;
|
| 89 |
+
readonly items: readonly DebugGraphNode[];
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export function DiGraphPanel({ graph }: { graph: DebugGraph }) {
|
| 93 |
+
const [scopeFilter, setScopeFilter] = useState('');
|
| 94 |
+
/** Selected row id per column — path[i] is the row expanded into column i+1. */
|
| 95 |
+
const [path, setPath] = useState<readonly string[]>([]);
|
| 96 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 97 |
+
|
| 98 |
+
const allScopes = useMemo(
|
| 99 |
+
() => [...new Set(graph.nodes.map((n) => n.scopePath))].toSorted(compareScopePaths),
|
| 100 |
+
[graph],
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
+
const nodesById = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph]);
|
| 104 |
+
|
| 105 |
+
/** Direct dependencies per consumer id (edge `to` endpoints), deduped. */
|
| 106 |
+
const depsOf = useMemo(() => {
|
| 107 |
+
const acc = new Map<string, Set<string>>();
|
| 108 |
+
for (const e of graph.edges) {
|
| 109 |
+
const set = acc.get(e.from) ?? new Set<string>();
|
| 110 |
+
set.add(e.to);
|
| 111 |
+
acc.set(e.from, set);
|
| 112 |
+
}
|
| 113 |
+
return acc;
|
| 114 |
+
}, [graph]);
|
| 115 |
+
|
| 116 |
+
/** Services nothing consumes (never an edge's `to`), scope-filtered. */
|
| 117 |
+
const roots = useMemo(() => {
|
| 118 |
+
const consumed = new Set(graph.edges.map((e) => e.to));
|
| 119 |
+
const pool =
|
| 120 |
+
scopeFilter === '' ? graph.nodes : graph.nodes.filter((n) => n.scopePath === scopeFilter);
|
| 121 |
+
return pool.filter((n) => !consumed.has(n.id)).toSorted(byToken);
|
| 122 |
+
}, [graph, scopeFilter]);
|
| 123 |
+
|
| 124 |
+
// Walk the path, truncating it at the first entry that is no longer
|
| 125 |
+
// selectable (scope filter change or data refresh), so rendering and
|
| 126 |
+
// follow-up clicks always work from a valid prefix.
|
| 127 |
+
const { columns, usedPath } = useMemo(() => {
|
| 128 |
+
const columns: DepsColumn[] = [];
|
| 129 |
+
const usedPath: string[] = [];
|
| 130 |
+
columns.push({
|
| 131 |
+
title: `roots · ${scopeFilter === '' ? 'all scopes' : scopeFilter}`,
|
| 132 |
+
homeScope: scopeFilter,
|
| 133 |
+
items: roots,
|
| 134 |
+
});
|
| 135 |
+
let items = roots;
|
| 136 |
+
for (const id of path) {
|
| 137 |
+
if (!items.some((n) => n.id === id)) break;
|
| 138 |
+
usedPath.push(id);
|
| 139 |
+
const deps = [...(depsOf.get(id) ?? [])]
|
| 140 |
+
.map((depId) => nodesById.get(depId) ?? fallbackNode(depId))
|
| 141 |
+
.toSorted(byToken);
|
| 142 |
+
if (deps.length === 0) break;
|
| 143 |
+
const parent = nodesById.get(id);
|
| 144 |
+
columns.push({
|
| 145 |
+
title: parent?.token ?? id,
|
| 146 |
+
homeScope: parent?.scopePath ?? '',
|
| 147 |
+
items: deps,
|
| 148 |
+
});
|
| 149 |
+
items = deps;
|
| 150 |
+
}
|
| 151 |
+
return { columns, usedPath };
|
| 152 |
+
}, [scopeFilter, path, roots, depsOf, nodesById]);
|
| 153 |
+
|
| 154 |
+
// Keep the deepest column in view as the path grows.
|
| 155 |
+
useEffect(() => {
|
| 156 |
+
const el = scrollRef.current;
|
| 157 |
+
if (el !== null) el.scrollLeft = el.scrollWidth;
|
| 158 |
+
}, [columns.length]);
|
| 159 |
+
|
| 160 |
+
const select = (columnIndex: number, id: string) => {
|
| 161 |
+
setPath([...usedPath.slice(0, columnIndex), id]);
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
// Path context: the root the current path starts at (column 0 only holds
|
| 165 |
+
// roots, so this is always one — guarded anyway).
|
| 166 |
+
const pathRootId = usedPath[0];
|
| 167 |
+
|
| 168 |
+
// Path-highlight: nodes reachable (along `to`) from the path's root,
|
| 169 |
+
// tinted in the fixed level-0 color. Only the path root's closure is
|
| 170 |
+
// needed — computed lazily per selection.
|
| 171 |
+
const pathTint = pathRootId === undefined ? undefined : `${levelColor(0)}1a`;
|
| 172 |
+
const pathClosure = useMemo(() => {
|
| 173 |
+
if (pathRootId === undefined) return undefined;
|
| 174 |
+
const seen = new Set<string>();
|
| 175 |
+
const stack = [pathRootId];
|
| 176 |
+
while (stack.length > 0) {
|
| 177 |
+
const id = stack.pop() ?? '';
|
| 178 |
+
if (seen.has(id)) continue;
|
| 179 |
+
seen.add(id);
|
| 180 |
+
for (const dep of depsOf.get(id) ?? []) stack.push(dep);
|
| 181 |
+
}
|
| 182 |
+
return seen;
|
| 183 |
+
}, [pathRootId, depsOf]);
|
| 184 |
+
|
| 185 |
+
return (
|
| 186 |
+
<div>
|
| 187 |
+
<div className="mb-2 flex items-center gap-3">
|
| 188 |
+
<select
|
| 189 |
+
className="rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
|
| 190 |
+
value={scopeFilter}
|
| 191 |
+
onChange={(e) => {
|
| 192 |
+
setScopeFilter(e.target.value);
|
| 193 |
+
}}
|
| 194 |
+
>
|
| 195 |
+
<option value="">all scopes</option>
|
| 196 |
+
{allScopes.map((scopePath) => (
|
| 197 |
+
<option key={scopePath} value={scopePath}>
|
| 198 |
+
{scopePath}
|
| 199 |
+
</option>
|
| 200 |
+
))}
|
| 201 |
+
</select>
|
| 202 |
+
<span className="flex items-center gap-2 text-[10px] text-neutral-500">
|
| 203 |
+
{(Object.keys(STATE_COLORS) as (UnitState | 'none')[]).map((state) => (
|
| 204 |
+
<span key={state} className="flex items-center gap-1">
|
| 205 |
+
<span
|
| 206 |
+
className="inline-block h-2 w-2 rounded-full"
|
| 207 |
+
style={{ backgroundColor: STATE_COLORS[state] }}
|
| 208 |
+
/>
|
| 209 |
+
{state === 'none' ? 'unseen' : state}
|
| 210 |
+
</span>
|
| 211 |
+
))}
|
| 212 |
+
</span>
|
| 213 |
+
</div>
|
| 214 |
+
{graph.nodes.length === 0 ? (
|
| 215 |
+
<div className="text-[11px] text-neutral-600 italic">no nodes</div>
|
| 216 |
+
) : (
|
| 217 |
+
<div
|
| 218 |
+
ref={scrollRef}
|
| 219 |
+
className="flex h-[calc(100vh-150px)] min-h-[320px] overflow-x-auto rounded-lg border border-neutral-800 bg-neutral-950"
|
| 220 |
+
>
|
| 221 |
+
{columns.map((column, columnIndex) => (
|
| 222 |
+
<DepsColumnView
|
| 223 |
+
key={columnIndex}
|
| 224 |
+
column={column}
|
| 225 |
+
columnIndex={columnIndex}
|
| 226 |
+
usedPath={usedPath}
|
| 227 |
+
depsOf={depsOf}
|
| 228 |
+
nodesById={nodesById}
|
| 229 |
+
pathTint={pathTint}
|
| 230 |
+
pathClosure={pathClosure}
|
| 231 |
+
onSelect={select}
|
| 232 |
+
/>
|
| 233 |
+
))}
|
| 234 |
+
</div>
|
| 235 |
+
)}
|
| 236 |
+
</div>
|
| 237 |
+
);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
function DepsColumnView({
|
| 241 |
+
column,
|
| 242 |
+
columnIndex,
|
| 243 |
+
usedPath,
|
| 244 |
+
depsOf,
|
| 245 |
+
nodesById,
|
| 246 |
+
pathTint,
|
| 247 |
+
pathClosure,
|
| 248 |
+
onSelect,
|
| 249 |
+
}: {
|
| 250 |
+
column: DepsColumn;
|
| 251 |
+
columnIndex: number;
|
| 252 |
+
usedPath: readonly string[];
|
| 253 |
+
depsOf: ReadonlyMap<string, ReadonlySet<string>>;
|
| 254 |
+
nodesById: ReadonlyMap<string, DebugGraphNode>;
|
| 255 |
+
pathTint?: string;
|
| 256 |
+
pathClosure?: ReadonlySet<string>;
|
| 257 |
+
onSelect: (columnIndex: number, id: string) => void;
|
| 258 |
+
}) {
|
| 259 |
+
const mixedScopes = new Set(column.items.map((n) => n.scopePath)).size > 1;
|
| 260 |
+
const selectedId = usedPath[columnIndex];
|
| 261 |
+
return (
|
| 262 |
+
<div className="w-[230px] shrink-0 overflow-y-auto border-r border-neutral-800 last:border-r-0">
|
| 263 |
+
<div className="sticky top-0 flex items-baseline gap-2 border-b border-neutral-800 bg-neutral-950 px-2 py-1">
|
| 264 |
+
{columnIndex > 0 ? (
|
| 265 |
+
<span
|
| 266 |
+
className="h-2.5 w-2.5 shrink-0 self-center rounded-sm"
|
| 267 |
+
style={{ background: levelColor(columnIndex - 1) }}
|
| 268 |
+
title={`dependencies of ${column.title}`}
|
| 269 |
+
/>
|
| 270 |
+
) : null}
|
| 271 |
+
<span className="min-w-0 truncate font-mono text-[10px] font-semibold text-neutral-400">
|
| 272 |
+
{column.title}
|
| 273 |
+
</span>
|
| 274 |
+
<span className="ml-auto shrink-0 text-[9px] text-neutral-600">{column.items.length}</span>
|
| 275 |
+
</div>
|
| 276 |
+
<div className="p-1">
|
| 277 |
+
{column.items.length === 0 ? (
|
| 278 |
+
<div className="px-2 py-1 text-[10px] text-neutral-600 italic">
|
| 279 |
+
{columnIndex === 0 ? 'no roots' : 'no nodes'}
|
| 280 |
+
</div>
|
| 281 |
+
) : (
|
| 282 |
+
column.items.map((node) => {
|
| 283 |
+
const hasDeps = (depsOf.get(node.id)?.size ?? 0) > 0;
|
| 284 |
+
const selected = node.id === selectedId;
|
| 285 |
+
const inPath = usedPath.slice(0, columnIndex).includes(node.id);
|
| 286 |
+
const showScope =
|
| 287 |
+
mixedScopes || (column.homeScope !== '' && node.scopePath !== column.homeScope);
|
| 288 |
+
// Path-scoped relation bars: column 0 rows carry the level-0
|
| 289 |
+
// color; expanded columns carry one bar per path ancestor with a
|
| 290 |
+
// DIRECT edge to this node, each in that ancestor's level color.
|
| 291 |
+
const bars: { color: string; label: string }[] = [];
|
| 292 |
+
if (columnIndex === 0) {
|
| 293 |
+
bars.push({ color: levelColor(0), label: node.token });
|
| 294 |
+
} else {
|
| 295 |
+
usedPath.slice(0, columnIndex).forEach((ancestorId, depth) => {
|
| 296 |
+
if (depsOf.get(ancestorId)?.has(node.id) === true) {
|
| 297 |
+
bars.push({
|
| 298 |
+
color: levelColor(depth),
|
| 299 |
+
label: nodesById.get(ancestorId)?.token ?? ancestorId,
|
| 300 |
+
});
|
| 301 |
+
}
|
| 302 |
+
});
|
| 303 |
+
}
|
| 304 |
+
const inPathTree = !selected && pathClosure?.has(node.id) === true;
|
| 305 |
+
const details = [
|
| 306 |
+
`state: ${node.state ?? 'unseen'}`,
|
| 307 |
+
node.uid !== undefined ? `#${node.uid}` : undefined,
|
| 308 |
+
inPath ? 'already in path' : undefined,
|
| 309 |
+
columnIndex > 0 && bars.length > 0
|
| 310 |
+
? `direct dependency of: ${bars.map((b) => b.label).join(', ')}`
|
| 311 |
+
: undefined,
|
| 312 |
+
]
|
| 313 |
+
.filter((s) => s !== undefined)
|
| 314 |
+
.join('\n');
|
| 315 |
+
return (
|
| 316 |
+
<button
|
| 317 |
+
key={node.id}
|
| 318 |
+
type="button"
|
| 319 |
+
title={`${node.id}\n${details}`}
|
| 320 |
+
onClick={() => {
|
| 321 |
+
onSelect(columnIndex, node.id);
|
| 322 |
+
}}
|
| 323 |
+
style={{
|
| 324 |
+
backgroundColor: inPathTree ? pathTint : undefined,
|
| 325 |
+
}}
|
| 326 |
+
className={`mb-0.5 flex w-full items-stretch gap-1.5 rounded px-1.5 py-0.5 text-left font-mono text-[11px] ${
|
| 327 |
+
selected
|
| 328 |
+
? 'bg-neutral-800 text-sky-400'
|
| 329 |
+
: hasDeps
|
| 330 |
+
? 'text-neutral-300 hover:bg-neutral-800/60'
|
| 331 |
+
: 'text-neutral-500 hover:bg-neutral-800/60'
|
| 332 |
+
}`}
|
| 333 |
+
>
|
| 334 |
+
<span className="flex shrink-0 gap-[2px] self-stretch">
|
| 335 |
+
{bars.map((bar, i) => (
|
| 336 |
+
<span
|
| 337 |
+
key={i}
|
| 338 |
+
className="w-[3px] rounded-sm"
|
| 339 |
+
style={{ background: bar.color }}
|
| 340 |
+
title={bar.label}
|
| 341 |
+
/>
|
| 342 |
+
))}
|
| 343 |
+
</span>
|
| 344 |
+
<span
|
| 345 |
+
className="mt-1 inline-block h-2 w-2 shrink-0 rounded-full"
|
| 346 |
+
style={{ backgroundColor: STATE_COLORS[node.state ?? 'none'] }}
|
| 347 |
+
/>
|
| 348 |
+
<span className="min-w-0 flex-1 self-center">
|
| 349 |
+
<span className="block truncate">{node.token}</span>
|
| 350 |
+
{showScope ? (
|
| 351 |
+
<span className="block truncate text-[9px] text-neutral-600">
|
| 352 |
+
{node.scopePath}
|
| 353 |
+
</span>
|
| 354 |
+
) : null}
|
| 355 |
+
</span>
|
| 356 |
+
{inPath ? (
|
| 357 |
+
<span
|
| 358 |
+
className="shrink-0 self-center text-[10px] text-amber-400"
|
| 359 |
+
title="already in path"
|
| 360 |
+
>
|
| 361 |
+
↩
|
| 362 |
+
</span>
|
| 363 |
+
) : null}
|
| 364 |
+
<span className="w-3 shrink-0 self-center text-right text-neutral-600">
|
| 365 |
+
{hasDeps ? '›' : ''}
|
| 366 |
+
</span>
|
| 367 |
+
</button>
|
| 368 |
+
);
|
| 369 |
+
})
|
| 370 |
+
)}
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
);
|
| 374 |
+
}
|
apps/kimi-inspect/src/components/methodArgs.test.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest';
|
| 2 |
+
|
| 3 |
+
import { buildArgs, parseParamFields, smartParse } from './methodArgs';
|
| 4 |
+
|
| 5 |
+
describe('parseParamFields', () => {
|
| 6 |
+
it('parses a single named parameter', () => {
|
| 7 |
+
expect(parseParamFields('sessionId')).toEqual([{ kind: 'value', name: 'sessionId' }]);
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
it('parses several named parameters', () => {
|
| 11 |
+
expect(parseParamFields('id, decision')).toEqual([
|
| 12 |
+
{ kind: 'value', name: 'id' },
|
| 13 |
+
{ kind: 'value', name: 'decision' },
|
| 14 |
+
]);
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
it('keeps declared defaults', () => {
|
| 18 |
+
expect(parseParamFields("count = 1, mode = 'auto'")).toEqual([
|
| 19 |
+
{ kind: 'value', name: 'count', defaultValue: '1' },
|
| 20 |
+
{ kind: 'value', name: 'mode', defaultValue: "'auto'" },
|
| 21 |
+
]);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it('does not split commas inside defaults', () => {
|
| 25 |
+
expect(parseParamFields("ids = ['a', 'b'], flag")).toEqual([
|
| 26 |
+
{ kind: 'value', name: 'ids', defaultValue: "['a', 'b']" },
|
| 27 |
+
{ kind: 'value', name: 'flag' },
|
| 28 |
+
]);
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
it('expands a destructured object parameter into keys', () => {
|
| 32 |
+
expect(parseParamFields('{ workspaceId, limit }')).toEqual([
|
| 33 |
+
{ kind: 'object', name: '{ workspaceId, limit }', keys: ['workspaceId', 'limit'] },
|
| 34 |
+
]);
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
it('strips key defaults and renames in object patterns', () => {
|
| 38 |
+
expect(parseParamFields('{ limit = 10, workspaceId: ws }')).toEqual([
|
| 39 |
+
{
|
| 40 |
+
kind: 'object',
|
| 41 |
+
name: '{ limit = 10, workspaceId: ws }',
|
| 42 |
+
keys: ['limit', 'workspaceId'],
|
| 43 |
+
},
|
| 44 |
+
]);
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it('keeps the object-pattern default', () => {
|
| 48 |
+
expect(parseParamFields('{ a, b } = {}')).toEqual([
|
| 49 |
+
{ kind: 'object', name: '{ a, b } = {}', keys: ['a', 'b'], defaultValue: '{}' },
|
| 50 |
+
]);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it('drops rest elements inside object patterns', () => {
|
| 54 |
+
expect(parseParamFields('{ a, ...rest }')).toEqual([
|
| 55 |
+
{ kind: 'object', name: '{ a, ...rest }', keys: ['a'] },
|
| 56 |
+
]);
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it('falls back to raw for rest and array patterns', () => {
|
| 60 |
+
expect(parseParamFields('...args')).toEqual([{ kind: 'raw', label: '...args' }]);
|
| 61 |
+
expect(parseParamFields('[a, b]')).toEqual([{ kind: 'raw', label: '[a, b]' }]);
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
it('returns no fields for empty params', () => {
|
| 65 |
+
expect(parseParamFields('')).toEqual([]);
|
| 66 |
+
});
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
describe('smartParse', () => {
|
| 70 |
+
it('parses JSON values', () => {
|
| 71 |
+
expect(smartParse('5')).toBe(5);
|
| 72 |
+
expect(smartParse('true')).toBe(true);
|
| 73 |
+
expect(smartParse('{"a":1}')).toEqual({ a: 1 });
|
| 74 |
+
expect(smartParse('"x"')).toBe('x');
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it('passes non-JSON through as a plain string', () => {
|
| 78 |
+
expect(smartParse('main')).toBe('main');
|
| 79 |
+
expect(smartParse('k2-thinking')).toBe('k2-thinking');
|
| 80 |
+
});
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
describe('buildArgs', () => {
|
| 84 |
+
it('builds a single argument from a value field', () => {
|
| 85 |
+
const fields = parseParamFields('sessionId');
|
| 86 |
+
expect(buildArgs(fields, { '0': 'abc' })).toEqual(['abc']);
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
it('assembles a destructured object from its key fields', () => {
|
| 90 |
+
const fields = parseParamFields('{ workspaceId, limit }');
|
| 91 |
+
expect(buildArgs(fields, { '0.workspaceId': 'ws1', '0.limit': '5' })).toEqual([
|
| 92 |
+
{ workspaceId: 'ws1', limit: 5 },
|
| 93 |
+
]);
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
it('drops empty keys and omits a fully-empty object', () => {
|
| 97 |
+
const fields = parseParamFields('{ workspaceId, limit }');
|
| 98 |
+
expect(buildArgs(fields, { '0.limit': '5' })).toEqual([{ limit: 5 }]);
|
| 99 |
+
expect(buildArgs(fields, {})).toEqual([]);
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
it('uses declared defaults for empty value fields', () => {
|
| 103 |
+
const fields = parseParamFields("count = 1, mode = 'auto'");
|
| 104 |
+
expect(buildArgs(fields, {})).toEqual([1, 'auto']);
|
| 105 |
+
expect(buildArgs(fields, { '1': 'yolo' })).toEqual([1, 'yolo']);
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
it('truncates trailing empty params but keeps interior holes', () => {
|
| 109 |
+
const fields = parseParamFields('a, b, c');
|
| 110 |
+
expect(buildArgs(fields, { '0': 'x' })).toEqual(['x']);
|
| 111 |
+
// Interior hole: `b` empty, `c` filled — the hole serializes as null.
|
| 112 |
+
expect(JSON.parse(JSON.stringify(buildArgs(fields, { '0': 'x', '2': 'z' })))).toEqual([
|
| 113 |
+
'x',
|
| 114 |
+
null,
|
| 115 |
+
'z',
|
| 116 |
+
]);
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
it('parses raw fallback fields as strict JSON', () => {
|
| 120 |
+
const fields = parseParamFields('...args');
|
| 121 |
+
expect(buildArgs(fields, { '0': '[1, 2]' })).toEqual([[1, 2]]);
|
| 122 |
+
expect(() => buildArgs(fields, { '0': 'not json' })).toThrow();
|
| 123 |
+
});
|
| 124 |
+
});
|
apps/kimi-inspect/src/components/methodArgs.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Structured method-argument editing for the dynamic Service cards, driven
|
| 3 |
+
* only by the wire `params` string (the declared parameter list parsed from
|
| 4 |
+
* `Function#toString` server-side — names only, no per-method schemas).
|
| 5 |
+
*
|
| 6 |
+
* `parseParamFields` splits the params string into editable fields:
|
| 7 |
+
* - a named parameter (`title`, `count = 1`) becomes one input; an empty
|
| 8 |
+
* input falls back to the declared default;
|
| 9 |
+
* - a destructured object parameter (`{ workspaceId, limit }`) becomes one
|
| 10 |
+
* input per key, assembled back into an object (empty keys are dropped);
|
| 11 |
+
* - anything unparseable (rest params, array patterns) falls back to one
|
| 12 |
+
* raw JSON input — the old behavior.
|
| 13 |
+
*
|
| 14 |
+
* Field values are smart-parsed (`smartParse`): text that parses as JSON is
|
| 15 |
+
* passed through parsed (numbers, booleans, objects, quoted strings),
|
| 16 |
+
* anything else goes as a plain string — so `main` needs no quotes while
|
| 17 |
+
* `{ "a": 1 }` still works.
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
export type ParamField =
|
| 21 |
+
| { readonly kind: 'value'; readonly name: string; readonly defaultValue?: string }
|
| 22 |
+
| {
|
| 23 |
+
readonly kind: 'object';
|
| 24 |
+
/** Original pattern text, used as the group label (e.g. `{ workspaceId, limit }`). */
|
| 25 |
+
readonly name: string;
|
| 26 |
+
readonly keys: readonly string[];
|
| 27 |
+
readonly defaultValue?: string;
|
| 28 |
+
}
|
| 29 |
+
| { readonly kind: 'raw'; readonly label: string };
|
| 30 |
+
|
| 31 |
+
/** Input state key for a field: the param index, or `index.key` for object keys. */
|
| 32 |
+
export function fieldKey(index: number, key?: string): string {
|
| 33 |
+
return key === undefined ? String(index) : `${index}.${key}`;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Split a parameter list at top-level commas, tracking `(){}[]` nesting and
|
| 38 |
+
* string literals (defaults can contain commas, e.g. `= ['a', 'b']`).
|
| 39 |
+
*/
|
| 40 |
+
function splitTopLevel(src: string): string[] {
|
| 41 |
+
const parts: string[] = [];
|
| 42 |
+
let depth = 0;
|
| 43 |
+
let quote: string | null = null;
|
| 44 |
+
let start = 0;
|
| 45 |
+
for (let i = 0; i < src.length; i++) {
|
| 46 |
+
const ch = src[i]!;
|
| 47 |
+
if (quote !== null) {
|
| 48 |
+
if (ch === '\\') i++;
|
| 49 |
+
else if (ch === quote) quote = null;
|
| 50 |
+
continue;
|
| 51 |
+
}
|
| 52 |
+
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
|
| 53 |
+
else if (ch === '(' || ch === '{' || ch === '[') depth++;
|
| 54 |
+
else if (ch === ')' || ch === '}' || ch === ']') depth--;
|
| 55 |
+
else if (ch === ',' && depth === 0) {
|
| 56 |
+
parts.push(src.slice(start, i));
|
| 57 |
+
start = i + 1;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
parts.push(src.slice(start));
|
| 61 |
+
return parts;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/** Index of the first `=` at nesting depth 0 (skipping `=>`, `==`, `===`), or -1. */
|
| 65 |
+
function indexOfTopLevelEquals(src: string): number {
|
| 66 |
+
let depth = 0;
|
| 67 |
+
let quote: string | null = null;
|
| 68 |
+
for (let i = 0; i < src.length; i++) {
|
| 69 |
+
const ch = src[i]!;
|
| 70 |
+
if (quote !== null) {
|
| 71 |
+
if (ch === '\\') i++;
|
| 72 |
+
else if (ch === quote) quote = null;
|
| 73 |
+
continue;
|
| 74 |
+
}
|
| 75 |
+
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
|
| 76 |
+
else if (ch === '(' || ch === '{' || ch === '[') depth++;
|
| 77 |
+
else if (ch === ')' || ch === '}' || ch === ']') depth--;
|
| 78 |
+
else if (ch === '=' && depth === 0 && src[i + 1] !== '=' && src[i + 1] !== '>') return i;
|
| 79 |
+
}
|
| 80 |
+
return -1;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** Index of the `}` matching the `{` at position 0 of `src`, or -1. */
|
| 84 |
+
function matchingBrace(src: string): number {
|
| 85 |
+
let depth = 0;
|
| 86 |
+
let quote: string | null = null;
|
| 87 |
+
for (let i = 0; i < src.length; i++) {
|
| 88 |
+
const ch = src[i]!;
|
| 89 |
+
if (quote !== null) {
|
| 90 |
+
if (ch === '\\') i++;
|
| 91 |
+
else if (ch === quote) quote = null;
|
| 92 |
+
continue;
|
| 93 |
+
}
|
| 94 |
+
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
|
| 95 |
+
else if (ch === '{') depth++;
|
| 96 |
+
else if (ch === '}') {
|
| 97 |
+
depth--;
|
| 98 |
+
if (depth === 0) return i;
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
return -1;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/** Property name out of one object-pattern entry: `a`, `a: b`, `a = 1`, `a: b = 1` → `a`. */
|
| 105 |
+
function objectPatternKey(entry: string): string {
|
| 106 |
+
const colon = entry.indexOf(':');
|
| 107 |
+
const eq = indexOfTopLevelEquals(entry);
|
| 108 |
+
let end = entry.length;
|
| 109 |
+
if (colon !== -1) end = Math.min(end, colon);
|
| 110 |
+
if (eq !== -1) end = Math.min(end, eq);
|
| 111 |
+
return entry.slice(0, end).trim();
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/** Parse the wire `params` string into editable fields (see file header). */
|
| 115 |
+
export function parseParamFields(params: string): readonly ParamField[] {
|
| 116 |
+
return splitTopLevel(params)
|
| 117 |
+
.map((s) => s.trim())
|
| 118 |
+
.filter((s) => s !== '')
|
| 119 |
+
.map((part): ParamField => {
|
| 120 |
+
if (part.startsWith('{')) {
|
| 121 |
+
const close = matchingBrace(part);
|
| 122 |
+
if (close === -1) return { kind: 'raw', label: part };
|
| 123 |
+
const keys = splitTopLevel(part.slice(1, close))
|
| 124 |
+
.map((k) => objectPatternKey(k.trim()))
|
| 125 |
+
.filter((k) => k !== '' && !k.startsWith('...'));
|
| 126 |
+
const rest = part.slice(close + 1).trim();
|
| 127 |
+
const defaultValue = rest.startsWith('=') ? rest.slice(1).trim() : undefined;
|
| 128 |
+
if (keys.length === 0) return { kind: 'raw', label: part };
|
| 129 |
+
return { kind: 'object', name: part, keys, defaultValue };
|
| 130 |
+
}
|
| 131 |
+
if (part.startsWith('[') || part.startsWith('...')) {
|
| 132 |
+
return { kind: 'raw', label: part };
|
| 133 |
+
}
|
| 134 |
+
const eq = indexOfTopLevelEquals(part);
|
| 135 |
+
if (eq !== -1) {
|
| 136 |
+
const name = part.slice(0, eq).trim();
|
| 137 |
+
const defaultValue = part.slice(eq + 1).trim();
|
| 138 |
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) return { kind: 'value', name, defaultValue };
|
| 139 |
+
return { kind: 'raw', label: part };
|
| 140 |
+
}
|
| 141 |
+
if (/^[A-Za-z_$][\w$]*$/.test(part)) return { kind: 'value', name: part };
|
| 142 |
+
return { kind: 'raw', label: part };
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/** Smart-parse one field value: JSON when it parses, the raw string otherwise. */
|
| 147 |
+
export function smartParse(raw: string): unknown {
|
| 148 |
+
try {
|
| 149 |
+
return JSON.parse(raw);
|
| 150 |
+
} catch {
|
| 151 |
+
// Declared defaults are source text, where strings use JS single-quote
|
| 152 |
+
// literals ('auto') that JSON.parse rejects — unquote those.
|
| 153 |
+
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
|
| 154 |
+
return raw.slice(1, -1).replaceAll(/\\(.)/g, '$1');
|
| 155 |
+
}
|
| 156 |
+
return raw;
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* Assemble the wire argument array from field inputs (keyed by `fieldKey`).
|
| 162 |
+
* Empty value fields fall back to their declared default; empty object
|
| 163 |
+
* fields drop the key, and a fully-empty object param is omitted. Trailing
|
| 164 |
+
* holes are truncated; interior holes serialize as `null` on the wire.
|
| 165 |
+
* Throws when a raw field contains invalid JSON.
|
| 166 |
+
*/
|
| 167 |
+
export function buildArgs(
|
| 168 |
+
fields: readonly ParamField[],
|
| 169 |
+
values: Readonly<Record<string, string>>,
|
| 170 |
+
): unknown[] {
|
| 171 |
+
const args: unknown[] = [];
|
| 172 |
+
fields.forEach((field, i) => {
|
| 173 |
+
if (field.kind === 'value') {
|
| 174 |
+
const raw = (values[fieldKey(i)] ?? '').trim();
|
| 175 |
+
if (raw !== '') args[i] = smartParse(raw);
|
| 176 |
+
else if (field.defaultValue !== undefined) args[i] = smartParse(field.defaultValue);
|
| 177 |
+
else args[i] = undefined;
|
| 178 |
+
} else if (field.kind === 'object') {
|
| 179 |
+
const obj: Record<string, unknown> = {};
|
| 180 |
+
let filled = false;
|
| 181 |
+
for (const key of field.keys) {
|
| 182 |
+
const raw = (values[fieldKey(i, key)] ?? '').trim();
|
| 183 |
+
if (raw !== '') {
|
| 184 |
+
obj[key] = smartParse(raw);
|
| 185 |
+
filled = true;
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
args[i] = filled ? obj : undefined;
|
| 189 |
+
} else {
|
| 190 |
+
const raw = (values[fieldKey(i)] ?? '').trim();
|
| 191 |
+
if (raw !== '') args[i] = JSON.parse(raw);
|
| 192 |
+
else args[i] = undefined;
|
| 193 |
+
}
|
| 194 |
+
});
|
| 195 |
+
let end = args.length;
|
| 196 |
+
while (end > 0 && args[end - 1] === undefined) end--;
|
| 197 |
+
return args.slice(0, end);
|
| 198 |
+
}
|
apps/kimi-inspect/src/connection.tsx
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Connection context — owns the inspect client (HTTP calls over the
|
| 3 |
+
* `/api/v1/debug` surface) built from the selected server URL + token.
|
| 4 |
+
*
|
| 5 |
+
* Three ways to connect:
|
| 6 |
+
* 1. deep link `?url=` / `?token=` or a previously saved manual config
|
| 7 |
+
* (persisted in localStorage);
|
| 8 |
+
* 2. zero-config discovery: on startup the dev middleware
|
| 9 |
+
* (`/__inspect/servers`) lists every local kap-server with the home
|
| 10 |
+
* token — the app connects straight to the remembered / proxy / first
|
| 11 |
+
* instance, persisting nothing but the picked URL (so a reload re-picks
|
| 12 |
+
* it while it is still alive, and never resurrects a stale port);
|
| 13 |
+
* 3. the manual form or a discovered-server card on the connect screen.
|
| 14 |
+
* `disconnect` suppresses the discovery bootstrap for the rest of the
|
| 15 |
+
* session so it lands on the connect screen instead of reconnecting.
|
| 16 |
+
*
|
| 17 |
+
* The client is built only after `probeDebugSurface` confirms the server
|
| 18 |
+
* mounts `/api/v1/debug` (kap-server started with `--debug-endpoints` on a
|
| 19 |
+
* loopback bind). There is no fallback surface — when the probe fails the
|
| 20 |
+
* connection stops on a dedicated error screen (with retry / reconfigure)
|
| 21 |
+
* instead of silently degrading.
|
| 22 |
+
*/
|
| 23 |
+
|
| 24 |
+
import {
|
| 25 |
+
createContext,
|
| 26 |
+
useCallback,
|
| 27 |
+
useContext,
|
| 28 |
+
useEffect,
|
| 29 |
+
useMemo,
|
| 30 |
+
useState,
|
| 31 |
+
type ReactNode,
|
| 32 |
+
} from 'react';
|
| 33 |
+
|
| 34 |
+
import { createInspectClient, probeDebugSurface, type InspectClient } from './channel';
|
| 35 |
+
import { fetchServerDiscovery, pickDefaultServer, useServerDiscovery } from './servers';
|
| 36 |
+
|
| 37 |
+
export interface ConnectionConfig {
|
| 38 |
+
/** Server base URL; empty string means same-origin (the Vite dev proxy). */
|
| 39 |
+
readonly url: string;
|
| 40 |
+
readonly token: string;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface ConnectOptions {
|
| 44 |
+
/** Persist the whole config (manual form / deep link). Default `true`;
|
| 45 |
+
* discovered connects pass `false` so a reload re-discovers fresh state. */
|
| 46 |
+
readonly persist?: boolean;
|
| 47 |
+
/** Remember just this URL as the preferred discovery pick (`kimi-inspect.server-url`). */
|
| 48 |
+
readonly rememberServerUrl?: string;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const STORAGE_KEY = 'kimi-inspect.connection';
|
| 52 |
+
const REMEMBERED_SERVER_KEY = 'kimi-inspect.server-url';
|
| 53 |
+
|
| 54 |
+
function readInitialConfig(): ConnectionConfig {
|
| 55 |
+
const params = new URLSearchParams(window.location.search);
|
| 56 |
+
const qUrl = params.get('url');
|
| 57 |
+
const qToken = params.get('token');
|
| 58 |
+
if (qUrl !== null || qToken !== null) {
|
| 59 |
+
return { url: qUrl ?? '', token: qToken ?? '' };
|
| 60 |
+
}
|
| 61 |
+
try {
|
| 62 |
+
const raw = localStorage.getItem(STORAGE_KEY);
|
| 63 |
+
if (raw !== null) {
|
| 64 |
+
const parsed = JSON.parse(raw) as ConnectionConfig;
|
| 65 |
+
return { url: parsed.url ?? '', token: parsed.token ?? '' };
|
| 66 |
+
}
|
| 67 |
+
} catch {
|
| 68 |
+
// corrupt storage — fall through to default
|
| 69 |
+
}
|
| 70 |
+
return { url: '', token: '' };
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/** Resolve the configured (possibly relative) URL to an absolute base for the client. */
|
| 74 |
+
export function resolveBaseUrl(url: string): string {
|
| 75 |
+
const trimmed = url.trim().replace(/\/$/, '');
|
| 76 |
+
if (trimmed === '') return window.location.origin;
|
| 77 |
+
return trimmed;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
interface ConnectionValue {
|
| 81 |
+
readonly config: ConnectionConfig;
|
| 82 |
+
readonly baseUrl: string;
|
| 83 |
+
readonly klient: InspectClient;
|
| 84 |
+
readonly connect: (config: ConnectionConfig, opts?: ConnectOptions) => void;
|
| 85 |
+
readonly disconnect: () => void;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const ConnectionContext = createContext<ConnectionValue | null>(null);
|
| 89 |
+
|
| 90 |
+
export function ConnectionProvider({ children }: { children: ReactNode }) {
|
| 91 |
+
const [config, setConfig] = useState<ConnectionConfig | null>(() => {
|
| 92 |
+
const initial = readInitialConfig();
|
| 93 |
+
// Auto-connect only when the user explicitly connected before (stored) or
|
| 94 |
+
// deep-linked (query). First visit goes through the discovery bootstrap.
|
| 95 |
+
const params = new URLSearchParams(window.location.search);
|
| 96 |
+
if (params.has('url') || params.has('token')) return initial;
|
| 97 |
+
return localStorage.getItem(STORAGE_KEY) !== null ? initial : null;
|
| 98 |
+
});
|
| 99 |
+
const [discovering, setDiscovering] = useState(config === null);
|
| 100 |
+
const [suppressDiscovery, setSuppressDiscovery] = useState(false);
|
| 101 |
+
|
| 102 |
+
// Discovery bootstrap: with nothing explicit configured, scan the local
|
| 103 |
+
// kap-server instance registry (via the dev middleware) and auto-connect.
|
| 104 |
+
useEffect(() => {
|
| 105 |
+
if (config !== null || suppressDiscovery) return;
|
| 106 |
+
let cancelled = false;
|
| 107 |
+
setDiscovering(true);
|
| 108 |
+
void fetchServerDiscovery().then((discovery) => {
|
| 109 |
+
if (cancelled) return;
|
| 110 |
+
setDiscovering(false);
|
| 111 |
+
if (discovery === null) return;
|
| 112 |
+
const pick = pickDefaultServer(discovery, localStorage.getItem(REMEMBERED_SERVER_KEY));
|
| 113 |
+
if (pick === undefined) return;
|
| 114 |
+
setConfig({ url: pick.url, token: discovery.token ?? '' });
|
| 115 |
+
});
|
| 116 |
+
return () => {
|
| 117 |
+
cancelled = true;
|
| 118 |
+
};
|
| 119 |
+
}, [config, suppressDiscovery]);
|
| 120 |
+
|
| 121 |
+
// Debug-surface probe: the app talks ONLY to `/api/v1/debug` (kap-server
|
| 122 |
+
// `--debug-endpoints`, loopback). The client is built once the probe for
|
| 123 |
+
// this exact config has succeeded; a failure is kept and rendered as a
|
| 124 |
+
// blocking error screen (no fallback surface exists anymore).
|
| 125 |
+
const [probe, setProbe] = useState<{
|
| 126 |
+
readonly key: string;
|
| 127 |
+
readonly error: string | null;
|
| 128 |
+
} | null>(null);
|
| 129 |
+
const [probeNonce, setProbeNonce] = useState(0);
|
| 130 |
+
const configKey = config === null ? null : `${resolveBaseUrl(config.url)}|${config.token.trim()}`;
|
| 131 |
+
|
| 132 |
+
useEffect(() => {
|
| 133 |
+
if (config === null || configKey === null) {
|
| 134 |
+
setProbe(null);
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
let cancelled = false;
|
| 138 |
+
const token = config.token.trim();
|
| 139 |
+
void probeDebugSurface({
|
| 140 |
+
baseUrl: resolveBaseUrl(config.url),
|
| 141 |
+
token: token === '' ? undefined : token,
|
| 142 |
+
}).then(
|
| 143 |
+
() => {
|
| 144 |
+
if (!cancelled) setProbe({ key: configKey, error: null });
|
| 145 |
+
},
|
| 146 |
+
(error: unknown) => {
|
| 147 |
+
if (cancelled) return;
|
| 148 |
+
const message = error instanceof Error ? error.message : String(error);
|
| 149 |
+
setProbe({ key: configKey, error: message });
|
| 150 |
+
},
|
| 151 |
+
);
|
| 152 |
+
return () => {
|
| 153 |
+
cancelled = true;
|
| 154 |
+
};
|
| 155 |
+
}, [config, configKey, probeNonce]);
|
| 156 |
+
|
| 157 |
+
const klient = useMemo(() => {
|
| 158 |
+
if (config === null || configKey === null) return null;
|
| 159 |
+
if (probe === null || probe.key !== configKey || probe.error !== null) return null;
|
| 160 |
+
const token = config.token.trim();
|
| 161 |
+
return createInspectClient({
|
| 162 |
+
url: resolveBaseUrl(config.url),
|
| 163 |
+
token: token === '' ? undefined : token,
|
| 164 |
+
});
|
| 165 |
+
}, [config, configKey, probe]);
|
| 166 |
+
|
| 167 |
+
const connect = useCallback((next: ConnectionConfig, opts: ConnectOptions = {}) => {
|
| 168 |
+
if (opts.persist ?? true) {
|
| 169 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
| 170 |
+
} else {
|
| 171 |
+
localStorage.removeItem(STORAGE_KEY);
|
| 172 |
+
}
|
| 173 |
+
if (opts.rememberServerUrl !== undefined) {
|
| 174 |
+
localStorage.setItem(REMEMBERED_SERVER_KEY, opts.rememberServerUrl);
|
| 175 |
+
}
|
| 176 |
+
setSuppressDiscovery(false);
|
| 177 |
+
setConfig(next);
|
| 178 |
+
}, []);
|
| 179 |
+
|
| 180 |
+
const disconnect = useCallback(() => {
|
| 181 |
+
localStorage.removeItem(STORAGE_KEY);
|
| 182 |
+
localStorage.removeItem(REMEMBERED_SERVER_KEY);
|
| 183 |
+
setSuppressDiscovery(true);
|
| 184 |
+
setConfig(null);
|
| 185 |
+
}, []);
|
| 186 |
+
|
| 187 |
+
const retryProbe = useCallback(() => {
|
| 188 |
+
setProbe(null);
|
| 189 |
+
setProbeNonce((n) => n + 1);
|
| 190 |
+
}, []);
|
| 191 |
+
|
| 192 |
+
const value = useMemo<ConnectionValue | null>(() => {
|
| 193 |
+
if (klient === null || config === null) return null;
|
| 194 |
+
return { config, baseUrl: resolveBaseUrl(config.url), klient, connect, disconnect };
|
| 195 |
+
}, [klient, config, connect, disconnect]);
|
| 196 |
+
|
| 197 |
+
const probeFailure =
|
| 198 |
+
config !== null && probe !== null && probe.key === configKey && probe.error !== null
|
| 199 |
+
? { baseUrl: resolveBaseUrl(config.url), error: probe.error }
|
| 200 |
+
: null;
|
| 201 |
+
|
| 202 |
+
return (
|
| 203 |
+
<ConnectionContext.Provider value={value}>
|
| 204 |
+
{value !== null ? (
|
| 205 |
+
children
|
| 206 |
+
) : probeFailure !== null ? (
|
| 207 |
+
<DebugSurfaceError
|
| 208 |
+
baseUrl={probeFailure.baseUrl}
|
| 209 |
+
error={probeFailure.error}
|
| 210 |
+
onRetry={retryProbe}
|
| 211 |
+
onBack={disconnect}
|
| 212 |
+
/>
|
| 213 |
+
) : config !== null ? (
|
| 214 |
+
<div className="flex h-screen items-center justify-center">
|
| 215 |
+
<div className="text-sm text-neutral-500">
|
| 216 |
+
Connecting to {resolveBaseUrl(config.url)}…
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
) : discovering && !suppressDiscovery ? (
|
| 220 |
+
<div className="flex h-screen items-center justify-center">
|
| 221 |
+
<div className="text-sm text-neutral-500">Discovering local kap-servers…</div>
|
| 222 |
+
</div>
|
| 223 |
+
) : (
|
| 224 |
+
<ConnectScreen onConnect={connect} initial={readInitialConfig()} />
|
| 225 |
+
)}
|
| 226 |
+
</ConnectionContext.Provider>
|
| 227 |
+
);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
export function useConnection(): ConnectionValue {
|
| 231 |
+
const value = useContext(ConnectionContext);
|
| 232 |
+
if (value === null) {
|
| 233 |
+
throw new Error('useConnection used before connecting');
|
| 234 |
+
}
|
| 235 |
+
return value;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/** Blocking screen when the server does not mount the debug RPC surface. */
|
| 239 |
+
function DebugSurfaceError({
|
| 240 |
+
baseUrl,
|
| 241 |
+
error,
|
| 242 |
+
onRetry,
|
| 243 |
+
onBack,
|
| 244 |
+
}: {
|
| 245 |
+
baseUrl: string;
|
| 246 |
+
error: string;
|
| 247 |
+
onRetry: () => void;
|
| 248 |
+
onBack: () => void;
|
| 249 |
+
}) {
|
| 250 |
+
return (
|
| 251 |
+
<div className="flex h-screen items-center justify-center">
|
| 252 |
+
<div className="w-[520px] rounded-lg border border-red-900/60 bg-neutral-900 p-6 shadow-xl">
|
| 253 |
+
<h1 className="mb-1 text-lg font-semibold text-red-300">Debug surface unavailable</h1>
|
| 254 |
+
<p className="mb-3 text-xs leading-relaxed text-neutral-400">
|
| 255 |
+
Kimi Inspect talks to kap-server exclusively over the debug RPC surface (
|
| 256 |
+
<code className="text-neutral-300">/api/v1/debug</code>), and{' '}
|
| 257 |
+
<code className="text-neutral-300">{baseUrl}</code> does not serve it. Start kap-server
|
| 258 |
+
with <code className="text-neutral-300">--debug-endpoints</code> on a loopback bind and
|
| 259 |
+
retry.
|
| 260 |
+
</p>
|
| 261 |
+
<div className="mb-4 rounded bg-red-950/50 px-2 py-1.5 font-mono text-[11px] text-red-400">
|
| 262 |
+
{error}
|
| 263 |
+
</div>
|
| 264 |
+
<div className="flex gap-2">
|
| 265 |
+
<button
|
| 266 |
+
className="rounded bg-sky-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-sky-500"
|
| 267 |
+
onClick={onRetry}
|
| 268 |
+
>
|
| 269 |
+
Retry
|
| 270 |
+
</button>
|
| 271 |
+
<button
|
| 272 |
+
className="rounded border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300 hover:bg-neutral-800"
|
| 273 |
+
onClick={onBack}
|
| 274 |
+
>
|
| 275 |
+
Change server
|
| 276 |
+
</button>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function ConnectScreen({
|
| 284 |
+
onConnect,
|
| 285 |
+
initial,
|
| 286 |
+
}: {
|
| 287 |
+
onConnect: (config: ConnectionConfig, opts?: ConnectOptions) => void;
|
| 288 |
+
initial: ConnectionConfig;
|
| 289 |
+
}) {
|
| 290 |
+
const [url, setUrl] = useState(initial.url);
|
| 291 |
+
const [token, setToken] = useState(initial.token);
|
| 292 |
+
const discovery = useServerDiscovery();
|
| 293 |
+
const servers = discovery.data?.servers ?? [];
|
| 294 |
+
return (
|
| 295 |
+
<div className="flex h-screen items-center justify-center">
|
| 296 |
+
<form
|
| 297 |
+
className="w-[420px] rounded-lg border border-neutral-800 bg-neutral-900 p-6 shadow-xl"
|
| 298 |
+
onSubmit={(e) => {
|
| 299 |
+
e.preventDefault();
|
| 300 |
+
onConnect({ url, token });
|
| 301 |
+
}}
|
| 302 |
+
>
|
| 303 |
+
<h1 className="mb-1 text-lg font-semibold text-neutral-100">Kimi Inspect</h1>
|
| 304 |
+
<p className="mb-5 text-xs text-neutral-500">
|
| 305 |
+
Connect to a kap-server started with{' '}
|
| 306 |
+
<code className="text-neutral-400">--debug-endpoints</code> (
|
| 307 |
+
<code className="text-neutral-400">/api/v1/debug</code>). Leave the URL empty to use the
|
| 308 |
+
same-origin dev proxy
|
| 309 |
+
{` (${__KIMI_INSPECT_PROXY_TARGET__})`}.
|
| 310 |
+
</p>
|
| 311 |
+
{servers.length > 0 ? (
|
| 312 |
+
<div className="mb-5">
|
| 313 |
+
<div className="mb-1 text-xs text-neutral-400">
|
| 314 |
+
Discovered on this machine{discovery.data?.home ? ` (${discovery.data.home})` : ''}
|
| 315 |
+
</div>
|
| 316 |
+
<div className="space-y-1.5">
|
| 317 |
+
{servers.map((s) => (
|
| 318 |
+
<button
|
| 319 |
+
key={s.id}
|
| 320 |
+
type="button"
|
| 321 |
+
className="flex w-full items-center gap-2 rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-left text-[12px] text-neutral-200 hover:border-sky-600"
|
| 322 |
+
onClick={() =>
|
| 323 |
+
onConnect(
|
| 324 |
+
{ url: s.url, token: discovery.data?.token ?? '' },
|
| 325 |
+
{ persist: false, rememberServerUrl: s.url },
|
| 326 |
+
)
|
| 327 |
+
}
|
| 328 |
+
>
|
| 329 |
+
<span className="font-mono">{s.url.replace(/^https?:\/\//, '')}</span>
|
| 330 |
+
{s.pid !== undefined ? (
|
| 331 |
+
<span className="text-[10px] text-neutral-500">pid {s.pid}</span>
|
| 332 |
+
) : null}
|
| 333 |
+
<span className="ml-auto text-[10px] uppercase text-neutral-600">{s.source}</span>
|
| 334 |
+
</button>
|
| 335 |
+
))}
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
) : null}
|
| 339 |
+
<label className="mb-1 block text-xs text-neutral-400">Server URL</label>
|
| 340 |
+
<input
|
| 341 |
+
className="mb-4 w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 outline-none focus:border-sky-600"
|
| 342 |
+
placeholder="http://127.0.0.1:58627 (empty = dev proxy)"
|
| 343 |
+
value={url}
|
| 344 |
+
onChange={(e) => setUrl(e.target.value)}
|
| 345 |
+
/>
|
| 346 |
+
<label className="mb-1 block text-xs text-neutral-400">Bearer token (optional)</label>
|
| 347 |
+
<input
|
| 348 |
+
className="mb-5 w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 outline-none focus:border-sky-600"
|
| 349 |
+
placeholder="~/.kimi-code/server.token"
|
| 350 |
+
value={token}
|
| 351 |
+
onChange={(e) => setToken(e.target.value)}
|
| 352 |
+
/>
|
| 353 |
+
<button
|
| 354 |
+
type="submit"
|
| 355 |
+
className="w-full rounded bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500"
|
| 356 |
+
>
|
| 357 |
+
Connect
|
| 358 |
+
</button>
|
| 359 |
+
</form>
|
| 360 |
+
</div>
|
| 361 |
+
);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
declare const __KIMI_INSPECT_PROXY_TARGET__: string;
|
apps/kimi-inspect/src/fs/api.test.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest';
|
| 2 |
+
|
| 3 |
+
import { fetchFsSuggest } from './api';
|
| 4 |
+
|
| 5 |
+
function okEnvelope(data: unknown) {
|
| 6 |
+
return { code: 0, msg: 'success', data, request_id: 'r1' };
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
function fakeFetch(envelope: unknown) {
|
| 10 |
+
const calls: { url: string; init?: RequestInit }[] = [];
|
| 11 |
+
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
|
| 12 |
+
calls.push({ url: String(url), init });
|
| 13 |
+
return { json: async () => envelope };
|
| 14 |
+
}) as unknown as typeof fetch;
|
| 15 |
+
return { calls, fetchImpl };
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const resultData = {
|
| 19 |
+
items: [
|
| 20 |
+
{
|
| 21 |
+
path: 'apps/desktop',
|
| 22 |
+
name: 'desktop',
|
| 23 |
+
kind: 'directory',
|
| 24 |
+
score: 0.9,
|
| 25 |
+
match_positions: [5, 6],
|
| 26 |
+
},
|
| 27 |
+
{ path: 'README.md', name: 'README.md', kind: 'file', score: 0.8, match_positions: [0, 1] },
|
| 28 |
+
{ path: 'broken' },
|
| 29 |
+
],
|
| 30 |
+
truncated: true,
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
describe('fetchFsSuggest', () => {
|
| 34 |
+
it('posts the roots suggestion request and maps items', async () => {
|
| 35 |
+
const { calls, fetchImpl } = fakeFetch(okEnvelope(resultData));
|
| 36 |
+
const result = await fetchFsSuggest({
|
| 37 |
+
baseUrl: 'http://h:1/',
|
| 38 |
+
token: 'tok',
|
| 39 |
+
roots: ['/repo', '/extra'],
|
| 40 |
+
query: 'apps/de',
|
| 41 |
+
limit: 20,
|
| 42 |
+
followGitignore: false,
|
| 43 |
+
showHidden: true,
|
| 44 |
+
includeGlobs: ['**/*.ts'],
|
| 45 |
+
excludeGlobs: ['dist/**'],
|
| 46 |
+
runtimeId: 'local',
|
| 47 |
+
fetchImpl,
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
expect(calls[0]!.url).toBe('http://h:1/api/v1/fs:suggest');
|
| 51 |
+
expect(calls[0]!.init?.method).toBe('POST');
|
| 52 |
+
expect(calls[0]!.init?.headers).toEqual({
|
| 53 |
+
'content-type': 'application/json',
|
| 54 |
+
authorization: 'Bearer tok',
|
| 55 |
+
});
|
| 56 |
+
expect(JSON.parse(calls[0]!.init?.body as string)).toEqual({
|
| 57 |
+
roots: ['/repo', '/extra'],
|
| 58 |
+
query: 'apps/de',
|
| 59 |
+
limit: 20,
|
| 60 |
+
follow_gitignore: false,
|
| 61 |
+
show_hidden: true,
|
| 62 |
+
include_globs: ['**/*.ts'],
|
| 63 |
+
exclude_globs: ['dist/**'],
|
| 64 |
+
runtime_id: 'local',
|
| 65 |
+
});
|
| 66 |
+
expect(result.items).toHaveLength(2);
|
| 67 |
+
expect(result.items[0]).toEqual({
|
| 68 |
+
path: 'apps/desktop',
|
| 69 |
+
name: 'desktop',
|
| 70 |
+
kind: 'directory',
|
| 71 |
+
score: 0.9,
|
| 72 |
+
matchPositions: [5, 6],
|
| 73 |
+
});
|
| 74 |
+
expect(result.truncated).toBe(true);
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it('omits optional fields and authorization when not configured', async () => {
|
| 78 |
+
const { calls, fetchImpl } = fakeFetch(okEnvelope({ items: [], truncated: false }));
|
| 79 |
+
await fetchFsSuggest({ baseUrl: 'http://h:1', roots: ['/repo'], query: '', fetchImpl });
|
| 80 |
+
expect(calls[0]!.init?.headers).toEqual({ 'content-type': 'application/json' });
|
| 81 |
+
expect(JSON.parse(calls[0]!.init?.body as string)).toEqual({
|
| 82 |
+
roots: ['/repo'],
|
| 83 |
+
query: '',
|
| 84 |
+
});
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
it('throws on a non-zero envelope code', async () => {
|
| 88 |
+
const { fetchImpl } = fakeFetch({ code: 40409, msg: 'root missing', data: null });
|
| 89 |
+
await expect(
|
| 90 |
+
fetchFsSuggest({ baseUrl: 'http://h:1', roots: ['/missing'], query: 'x', fetchImpl }),
|
| 91 |
+
).rejects.toThrow(/40409/);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
it('throws on a malformed payload', async () => {
|
| 95 |
+
const { fetchImpl } = fakeFetch(okEnvelope({ truncated: false }));
|
| 96 |
+
await expect(
|
| 97 |
+
fetchFsSuggest({ baseUrl: 'http://h:1', roots: ['/repo'], query: 'x', fetchImpl }),
|
| 98 |
+
).rejects.toThrow(/unexpected response shape/);
|
| 99 |
+
});
|
| 100 |
+
});
|
apps/kimi-inspect/src/fs/api.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type FsSuggestKind = 'file' | 'directory' | 'symlink';
|
| 2 |
+
|
| 3 |
+
export interface FsSuggestItem {
|
| 4 |
+
readonly path: string;
|
| 5 |
+
readonly name: string;
|
| 6 |
+
readonly kind: FsSuggestKind;
|
| 7 |
+
readonly score: number;
|
| 8 |
+
readonly matchPositions: readonly number[];
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export interface FsSuggestResult {
|
| 12 |
+
readonly items: readonly FsSuggestItem[];
|
| 13 |
+
readonly truncated: boolean;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export interface FetchFsSuggestOptions {
|
| 17 |
+
readonly baseUrl: string;
|
| 18 |
+
readonly token?: string;
|
| 19 |
+
readonly roots: readonly string[];
|
| 20 |
+
readonly query: string;
|
| 21 |
+
readonly limit?: number;
|
| 22 |
+
readonly followGitignore?: boolean;
|
| 23 |
+
readonly showHidden?: boolean;
|
| 24 |
+
readonly includeGlobs?: readonly string[];
|
| 25 |
+
readonly excludeGlobs?: readonly string[];
|
| 26 |
+
readonly runtimeId?: string;
|
| 27 |
+
readonly fetchImpl?: typeof fetch;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const KINDS = new Set<FsSuggestKind>(['file', 'directory', 'symlink']);
|
| 31 |
+
|
| 32 |
+
function parseItem(value: unknown): FsSuggestItem | undefined {
|
| 33 |
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
| 34 |
+
const item = value as Record<string, unknown>;
|
| 35 |
+
if (
|
| 36 |
+
typeof item['path'] !== 'string' ||
|
| 37 |
+
typeof item['name'] !== 'string' ||
|
| 38 |
+
typeof item['kind'] !== 'string' ||
|
| 39 |
+
!KINDS.has(item['kind'] as FsSuggestKind) ||
|
| 40 |
+
typeof item['score'] !== 'number' ||
|
| 41 |
+
!Array.isArray(item['match_positions']) ||
|
| 42 |
+
!item['match_positions'].every((position) => typeof position === 'number')
|
| 43 |
+
) {
|
| 44 |
+
return undefined;
|
| 45 |
+
}
|
| 46 |
+
return {
|
| 47 |
+
path: item['path'],
|
| 48 |
+
name: item['name'],
|
| 49 |
+
kind: item['kind'] as FsSuggestKind,
|
| 50 |
+
score: item['score'],
|
| 51 |
+
matchPositions: item['match_positions'] as number[],
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async function postSuggest(
|
| 56 |
+
body: Record<string, unknown>,
|
| 57 |
+
opts: { baseUrl: string; token?: string; fetchImpl?: typeof fetch },
|
| 58 |
+
): Promise<FsSuggestResult> {
|
| 59 |
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
| 60 |
+
if (opts.token !== undefined && opts.token !== '') {
|
| 61 |
+
headers['authorization'] = `Bearer ${opts.token}`;
|
| 62 |
+
}
|
| 63 |
+
const doFetch = opts.fetchImpl ?? fetch;
|
| 64 |
+
const res = await doFetch(`${opts.baseUrl.replace(/\/$/, '')}/api/v1/fs:suggest`, {
|
| 65 |
+
method: 'POST',
|
| 66 |
+
headers,
|
| 67 |
+
body: JSON.stringify(body),
|
| 68 |
+
});
|
| 69 |
+
const envelope = (await res.json()) as { code: number; msg: string; data: unknown };
|
| 70 |
+
if (envelope.code !== 0) {
|
| 71 |
+
throw new Error(`fs:suggest failed (${envelope.code}): ${envelope.msg}`);
|
| 72 |
+
}
|
| 73 |
+
const data = envelope.data as Record<string, unknown> | null;
|
| 74 |
+
if (data === null || typeof data !== 'object' || !Array.isArray(data['items'])) {
|
| 75 |
+
throw new Error('fs:suggest: unexpected response shape');
|
| 76 |
+
}
|
| 77 |
+
return {
|
| 78 |
+
items: (data['items'] as unknown[])
|
| 79 |
+
.map(parseItem)
|
| 80 |
+
.filter((item): item is FsSuggestItem => item !== undefined),
|
| 81 |
+
truncated: data['truncated'] === true,
|
| 82 |
+
};
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export async function fetchFsSuggest(opts: FetchFsSuggestOptions): Promise<FsSuggestResult> {
|
| 86 |
+
return postSuggest({
|
| 87 |
+
roots: [...opts.roots],
|
| 88 |
+
query: opts.query,
|
| 89 |
+
limit: opts.limit,
|
| 90 |
+
follow_gitignore: opts.followGitignore,
|
| 91 |
+
show_hidden: opts.showHidden,
|
| 92 |
+
include_globs: opts.includeGlobs,
|
| 93 |
+
exclude_globs: opts.excludeGlobs,
|
| 94 |
+
runtime_id: opts.runtimeId,
|
| 95 |
+
}, opts);
|
| 96 |
+
}
|
apps/kimi-inspect/src/index.css
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import 'tailwindcss';
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
color-scheme: dark;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
body {
|
| 8 |
+
margin: 0;
|
| 9 |
+
background: #0b0d10;
|
| 10 |
+
color: #d6dae0;
|
| 11 |
+
font-family:
|
| 12 |
+
ui-sans-serif,
|
| 13 |
+
system-ui,
|
| 14 |
+
-apple-system,
|
| 15 |
+
'Segoe UI',
|
| 16 |
+
Roboto,
|
| 17 |
+
sans-serif;
|
| 18 |
+
font-size: 13px;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
* {
|
| 22 |
+
scrollbar-width: thin;
|
| 23 |
+
scrollbar-color: #2b313a transparent;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
::-webkit-scrollbar {
|
| 27 |
+
width: 8px;
|
| 28 |
+
height: 8px;
|
| 29 |
+
}
|
| 30 |
+
::-webkit-scrollbar-thumb {
|
| 31 |
+
background: #2b313a;
|
| 32 |
+
border-radius: 4px;
|
| 33 |
+
}
|