feat: add /context slash command with a centered context modal
Typing /context in the composer opens a centered dialog with the current composition (provider-anchored headline, stacked bar, legend) and the last-10-turn trend chart — the same pushed contextTimeline projection the tab renders, no RPC, no polling. The command is the plugin's own '/' trigger source, fully client-side: no host descriptor, no session log records, nothing model-visible. The composer token is consumed only when the modal closes (deferred slash/input-consume-token with span / bare-token guards, fail-soft if the draft changed meanwhile). Esc, backdrop click, and the close button all close it and restore composer focus. Also extracts the provider-anchored headline derivation shared by the tab and the modal (headline.ts).
This commit is contained in:
1 parent
6cb61a413b
commit
bf4cdd1e58
10 files changed
+558
-35
No files matched your search
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `/context` — a client-owned slash command that opens the context modal
|
||||
* (current composition + recent trend) in the center of the page.
|
||||
*
|
||||
* Implemented as the plugin's own '/' trigger source instead of a host
|
||||
* command: nothing is dispatched to the host, no session log records are
|
||||
* written, and nothing becomes model-visible — the invocation never enters
|
||||
* the message history. Both paths answer `'handled'` and open the modal,
|
||||
* leaving the `/context` token in the composer while it is open; the modal's
|
||||
* close path consumes the token then (see modalStore.ts).
|
||||
*/
|
||||
|
||||
import { modalStoreOf, setPendingConsume } from './modalStore'
|
||||
import type { ClientCtx, InputTriggersFace } from './services'
|
||||
import type { ViewKit } from './viewkit'
|
||||
|
||||
const COMMAND = 'context'
|
||||
const LINE = '/' + COMMAND
|
||||
|
||||
export function registerContextCommand(ctx: ClientCtx, kit: ViewKit): void {
|
||||
ctx.effect(() => {
|
||||
// Soft dependency (ctx.get, not the inject list): a harness without the
|
||||
// input-trigger service keeps the tab working and only the command
|
||||
// absent.
|
||||
const inputTriggers = ctx.get('inputTriggers') as InputTriggersFace | undefined
|
||||
if (inputTriggers === undefined) return () => {}
|
||||
|
||||
return inputTriggers.registerSource({
|
||||
trigger: '/',
|
||||
name: COMMAND,
|
||||
order: 1,
|
||||
candidates: (_session, req) => {
|
||||
if (req.position !== 'leading') return Promise.resolve([])
|
||||
const query = req.query.trim().toLowerCase()
|
||||
if (query !== '' && !COMMAND.startsWith(query)) return Promise.resolve([])
|
||||
return Promise.resolve([{ name: COMMAND, description: kit.t('cmd.desc') }])
|
||||
},
|
||||
onPick: (pick) => {
|
||||
// Open and remember the token span: the modal's close path consumes
|
||||
// it (span CAS — a draft changed meanwhile is left untouched).
|
||||
setPendingConsume(pick.session.sessionId, { kind: 'span', span: pick.span })
|
||||
modalStoreOf(pick.session.sessionId).set(true)
|
||||
return 'handled'
|
||||
},
|
||||
matchEnter: (session, line) => {
|
||||
if (line !== LINE) return Promise.resolve(undefined)
|
||||
// Same deferred consume, bare-token guard (the trimmed draft must
|
||||
// still equal the command line when the modal closes).
|
||||
setPendingConsume(session.sessionId, { kind: 'bare-token', token: LINE })
|
||||
modalStoreOf(session.sessionId).set(true)
|
||||
return Promise.resolve<'handled'>('handled')
|
||||
},
|
||||
})
|
||||
}, 'dsh-context: /context command')
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* ContextModal — the /context command's centered dialog: the same data as
|
||||
* the Context tab (the pushed `contextTimeline` projection) distilled to
|
||||
* the overview (headline + composition bar + legend) and the last-10-turn
|
||||
* trend chart. Rendered from the `conversation.input.overlay` slot; opens
|
||||
* and closes through the per-session modal store (the trigger source flips
|
||||
* it, so no message ever enters the session history).
|
||||
*/
|
||||
|
||||
import type * as ReactNS from 'react'
|
||||
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
|
||||
import { headlineOf } from '../headline'
|
||||
import { modalStoreOf, takePendingConsume } from '../modalStore'
|
||||
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
|
||||
import { timelineOf } from '../services'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import { makeRequestDetail } from './requestDetail'
|
||||
import { makeLegend, makeStackedBar } from './stackedBar'
|
||||
import { aggregateByTurn, attachMarkers, makeTrendChart } from './trendChart'
|
||||
|
||||
import { React } from '../react'
|
||||
|
||||
/** How many of the most recent turns the modal's trend chart shows. */
|
||||
const TREND_TURNS = 10
|
||||
|
||||
export interface ContextModalProps extends SessionStandardProps {
|
||||
/** Bound selector hook over the per-session open flag (hooks compartment). */
|
||||
useContextModal?: (sel: (open: boolean) => boolean) => boolean
|
||||
}
|
||||
|
||||
export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextModalProps) => ReactNS.ReactElement | null {
|
||||
const { t, tr, fmt } = kit
|
||||
const sessions = ctx.get('sessions') as SessionsFace | undefined
|
||||
const StackedBar = makeStackedBar(kit)
|
||||
const Legend = makeLegend(kit)
|
||||
const TrendChart = makeTrendChart(kit)
|
||||
const RequestDetail = makeRequestDetail(kit, StackedBar)
|
||||
|
||||
return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null {
|
||||
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
|
||||
const open = typeof props.useContextModal === 'function' ? props.useContextModal(s => s) : false
|
||||
const data = typeof props.useProjection === 'function'
|
||||
? timelineOf(props.useProjection('contextTimeline'))
|
||||
: null
|
||||
const [selectedSeq, setSelectedSeq] = React.useState<number | null>(null)
|
||||
const [hoveredSeq, setHoveredSeq] = React.useState<number | null>(null)
|
||||
const [hoverCat, setHoverCat] = React.useState<string | null>(null)
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
if (sessionId === '') return
|
||||
modalStoreOf(sessionId).set(false)
|
||||
// Deferred token consumption: the `/context` token stayed in the
|
||||
// composer while the modal was open; consume it now through the scoped
|
||||
// input event (a stale guard — the user typed meanwhile — fails soft
|
||||
// inside the shell and leaves the draft untouched).
|
||||
const guard = takePendingConsume(sessionId)
|
||||
if (guard === undefined || sessions === undefined) return
|
||||
const scope = sessions.scope(sessionId)
|
||||
if (scope !== undefined) scope.bail(scope, 'slash/input-consume-token', { guard })
|
||||
}, [sessionId])
|
||||
|
||||
// Escape closes; on open the current focus (the composer) is captured
|
||||
// and restored on close, so typing resumes exactly where it stopped.
|
||||
React.useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== 'Escape') return
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
close()
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
if (previous !== null && document.contains(previous)) previous.focus()
|
||||
}
|
||||
}, [open, close])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const requests = data !== null ? data.requests || [] : []
|
||||
const events = data !== null ? data.events || [] : []
|
||||
// One bar per turn (each turn shown by its last step), newest 10 only.
|
||||
const turns = aggregateByTurn(requests).slice(-TREND_TURNS)
|
||||
const markers = attachMarkers(turns, events)
|
||||
|
||||
let pinnedReq: RequestRecord | null = null
|
||||
for (const req of turns) if (req.seq === selectedSeq) pinnedReq = req
|
||||
let activeReq: RequestRecord | null = null
|
||||
if (hoveredSeq !== null) {
|
||||
for (const req of turns) if (req.seq === hoveredSeq) activeReq = req
|
||||
}
|
||||
if (activeReq === null) activeReq = pinnedReq
|
||||
if (activeReq === null && turns.length > 0) activeReq = turns[turns.length - 1]
|
||||
const markerOf = (req: RequestRecord): ContextEventRecord | undefined => {
|
||||
const i = turns.indexOf(req)
|
||||
return i >= 0 ? markers[i] : undefined
|
||||
}
|
||||
|
||||
const head = data !== null ? headlineOf(data) : null
|
||||
|
||||
return (
|
||||
<div className="lc-modal-backdrop" onClick={close}>
|
||||
<div className="lc-modal-card" onClick={ev => { ev.stopPropagation() }}>
|
||||
<div className="lc-modal-head">
|
||||
<span className="lc-modal-title">{t('tab')}</span>
|
||||
{data !== null && (data.model || data.provider) ? (
|
||||
<span className="lc-card-sub">
|
||||
{(data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : '')}
|
||||
</span>
|
||||
) : null}
|
||||
<button className="lc-modal-close" aria-label={t('cmd.close')} onClick={close}>×</button>
|
||||
</div>
|
||||
|
||||
{data === null || head === null ? (
|
||||
<div className="lc-empty">{t('loading')}</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="lc-overview-num">
|
||||
<b>{fmt(head.tokens)}</b>
|
||||
<span>
|
||||
{head.window
|
||||
? ' / ' + fmt(head.window) + ' ' + tr('overview.ofWindow', { p: head.pct ?? 0 })
|
||||
: ' ' + t('overview.estimate')}
|
||||
</span>
|
||||
{!head.estimated ? <span className="lc-card-sub">{t('overview.splitEst')}</span> : null}
|
||||
</div>
|
||||
<StackedBar parts={head.parts} height={16} max={head.window} hoverKey={hoverCat} onHoverKey={setHoverCat} />
|
||||
<Legend parts={head.parts} hoverKey={hoverCat} onHoverKey={setHoverCat} />
|
||||
|
||||
<div className="lc-card-title lc-modal-trend">{t('trend.title')}</div>
|
||||
{turns.length === 0 ? (
|
||||
<div className="lc-empty">{t('trend.empty')}</div>
|
||||
) : (
|
||||
<div>
|
||||
<TrendChart
|
||||
requests={turns}
|
||||
markers={markers}
|
||||
selectedSeq={pinnedReq !== null ? pinnedReq.seq : null}
|
||||
hoveredSeq={hoveredSeq}
|
||||
activeTurn={null}
|
||||
granularity="turn"
|
||||
onSelect={setSelectedSeq}
|
||||
onHover={setHoveredSeq}
|
||||
onHoverTurn={() => {}}
|
||||
/>
|
||||
<RequestDetail request={activeReq} marker={activeReq !== null ? markerOf(activeReq) : undefined} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
*/
|
||||
|
||||
import type * as ReactNS from 'react'
|
||||
import type { ContextEventRecord, RequestRecord, Snapshot } from '../../shared/types'
|
||||
import { anchoredParts, partsOf } from '../categories'
|
||||
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
|
||||
import { headlineOf } from '../headline'
|
||||
import type { LocaleService, SessionStandardProps } from '../services'
|
||||
import { timelineOf } from '../services'
|
||||
import type { ClientCtx } from '../services'
|
||||
@@ -111,7 +111,6 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
|
||||
return <div className="lc-root" ref={rootRef}><div className="lc-empty">{t('loading')}</div></div>
|
||||
}
|
||||
|
||||
const current = data.current
|
||||
const requests = data.requests || []
|
||||
const events = data.events || []
|
||||
const nodes = data.nodes || []
|
||||
@@ -154,24 +153,8 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
|
||||
// the fixed 4-chars/token heuristic can undercount by ~10-15% on
|
||||
// CJK-heavy sessions — so this is the headline, and the heuristic
|
||||
// composition below is anchored to it (proportions stay heuristic).
|
||||
const occ = data.occupancy
|
||||
const projected = occ !== undefined && typeof occ.projectedTokens === 'number' ? occ.projectedTokens : undefined
|
||||
const lastReq = displayRequests.length > 0 ? displayRequests[displayRequests.length - 1] : null
|
||||
const derived = lastReq !== null && typeof lastReq.prompt === 'number'
|
||||
? lastReq.prompt + (current.total - lastReq.total)
|
||||
: undefined
|
||||
const occupancyTokens = projected ?? derived ?? null
|
||||
const occupancyWindow = occ !== undefined && typeof occ.contextWindow === 'number'
|
||||
? occ.contextWindow
|
||||
: data.contextWindow
|
||||
const headline = occupancyTokens ?? current.total
|
||||
const headlinePct = occupancyWindow
|
||||
? Math.min(100, Math.round(headline / occupancyWindow * 100))
|
||||
: null
|
||||
// Composition anchored to the provider total: the six categories keep
|
||||
// their heuristic RATIOS but sum to the headline, so the bar fill and
|
||||
// legend agree with the official ring instead of the underpriced raw sum.
|
||||
const parts = anchoredParts(partsOf(current), occupancyTokens !== null && headline > 0 ? headline : null)
|
||||
// Shared with the /context popup (headline.ts).
|
||||
const head = headlineOf(data)
|
||||
|
||||
return (
|
||||
<div className="lc-root" ref={rootRef}>
|
||||
@@ -188,16 +171,16 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
|
||||
</span>
|
||||
</div>
|
||||
<div className="lc-overview-num">
|
||||
<b>{fmt(headline)}</b>
|
||||
<b>{fmt(head.tokens)}</b>
|
||||
<span>
|
||||
{occupancyWindow
|
||||
? ' / ' + fmt(occupancyWindow) + ' ' + tr('overview.ofWindow', { p: headlinePct ?? 0 })
|
||||
{head.window
|
||||
? ' / ' + fmt(head.window) + ' ' + tr('overview.ofWindow', { p: head.pct ?? 0 })
|
||||
: ' ' + t('overview.estimate')}
|
||||
</span>
|
||||
{occupancyTokens !== null ? <span className="lc-card-sub">{t('overview.splitEst')}</span> : null}
|
||||
{!head.estimated ? <span className="lc-card-sub">{t('overview.splitEst')}</span> : null}
|
||||
</div>
|
||||
<StackedBar parts={parts} height={16} max={occupancyWindow} hoverKey={hoverCat} onHoverKey={setHoverCat} />
|
||||
<Legend parts={parts} hoverKey={hoverCat} onHoverKey={setHoverCat} />
|
||||
<StackedBar parts={head.parts} height={16} max={head.window} hoverKey={hoverCat} onHoverKey={setHoverCat} />
|
||||
<Legend parts={head.parts} hoverKey={hoverCat} onHoverKey={setHoverCat} />
|
||||
{(data.toolList && data.toolList.length > 0) ? (
|
||||
<div className="lc-tools">
|
||||
{t('tools.top')}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Provider-anchored headline derivation, shared by the Context tab and the
|
||||
* /context popup: the best-known occupancy of the next request, the route
|
||||
* capacity it scales against, and the composition parts anchored to that
|
||||
* total (heuristic ratios, provider-anchored sum — the same trick the
|
||||
* official ContextMeter uses).
|
||||
*/
|
||||
|
||||
import type { ContextTimeline } from '../shared/types'
|
||||
import { anchoredParts, partsOf, type PartsPart } from './categories'
|
||||
|
||||
export interface Headline {
|
||||
/** Best-known occupancy of the next request (projected ?? derived ?? heuristic total). */
|
||||
tokens: number
|
||||
/** Route capacity the headline scales against (may be unknown). */
|
||||
window?: number
|
||||
/** tokens / window, clamped to 100; null without a window. */
|
||||
pct: number | null
|
||||
/** Composition parts anchored to the provider total when one exists. */
|
||||
parts: PartsPart[]
|
||||
/** True when no provider anchor applied (parts sum to the heuristic total). */
|
||||
estimated: boolean
|
||||
}
|
||||
|
||||
export function headlineOf(data: ContextTimeline): Headline {
|
||||
const current = data.current
|
||||
const occ = data.occupancy
|
||||
const projected = occ !== undefined && typeof occ.projectedTokens === 'number' ? occ.projectedTokens : undefined
|
||||
const requests = data.requests || []
|
||||
const lastReq = requests.length > 0 ? requests[requests.length - 1] : null
|
||||
// Fallback anchor: the newest request's provider prompt plus the heuristic
|
||||
// surface movement since it was logged.
|
||||
const derived = lastReq !== null && typeof lastReq.prompt === 'number'
|
||||
? lastReq.prompt + (current.total - lastReq.total)
|
||||
: undefined
|
||||
const occupancyTokens = projected ?? derived ?? null
|
||||
const window = occ !== undefined && typeof occ.contextWindow === 'number' ? occ.contextWindow : data.contextWindow
|
||||
const tokens = occupancyTokens ?? current.total
|
||||
const pct = window !== undefined && window > 0 ? Math.min(100, Math.round(tokens / window * 100)) : null
|
||||
const parts = anchoredParts(partsOf(current), occupancyTokens !== null && tokens > 0 ? tokens : null)
|
||||
return { tokens, window, pct, parts, estimated: occupancyTokens === null }
|
||||
}
|
||||
@@ -64,6 +64,8 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'node.empty': '(空回复)',
|
||||
'node.nonText': '(非文本消息)',
|
||||
'node.snapshot': '快照: ',
|
||||
'cmd.desc': '查看上下文的构成和变化',
|
||||
'cmd.close': '关闭',
|
||||
}
|
||||
|
||||
export const DICT_EN: Record<string, string> = {
|
||||
@@ -125,4 +127,6 @@ export const DICT_EN: Record<string, string> = {
|
||||
'node.empty': '(empty reply)',
|
||||
'node.nonText': '(non-text message)',
|
||||
'node.snapshot': 'snapshot: ',
|
||||
'cmd.desc': 'View context makeup and how it evolves',
|
||||
'cmd.close': 'Close',
|
||||
}
|
||||
+18
-1
@@ -22,6 +22,9 @@
|
||||
*/
|
||||
|
||||
import { DICT_EN, DICT_ZH } from './i18n'
|
||||
import { registerContextCommand } from './command'
|
||||
import { makeContextModal } from './components/contextModal'
|
||||
import { modalStoreOf } from './modalStore'
|
||||
import type { ClientCtx } from './services'
|
||||
import { STYLES } from './styles'
|
||||
import { makeContextView } from './components/contextView'
|
||||
@@ -53,7 +56,8 @@ function apply(ctx: ClientCtx): void {
|
||||
}
|
||||
}, 'dsh-context: styles')
|
||||
|
||||
const ContextView = makeContextView(ctx, makeViewKit(t))
|
||||
const kit = makeViewKit(t)
|
||||
const ContextView = makeContextView(ctx, kit)
|
||||
ctx.slots.inject('conversation.view', () => {
|
||||
return ctx.slots.register(
|
||||
// order 20 renders right of Chat (0) and Trajectory (10); the locale
|
||||
@@ -62,6 +66,19 @@ function apply(ctx: ClientCtx): void {
|
||||
props => h(ContextView, props),
|
||||
)
|
||||
})
|
||||
|
||||
// `/context` slash command: opens the context modal (see command.ts for
|
||||
// the trigger source). The modal itself renders from the input overlay
|
||||
// slot, opened per session through the hooks-compartment store.
|
||||
registerContextCommand(ctx, kit)
|
||||
const ContextModal = makeContextModal(ctx, kit)
|
||||
ctx.slots.inject('conversation.input.overlay', () => {
|
||||
return ctx.slots.register(
|
||||
{ name: 'conversation.input.overlay', id: 'context-modal', order: 10, locale: NS,
|
||||
inject: (sessionId: string) => ({ hooks: { contextModal: modalStoreOf(sessionId) } }) },
|
||||
props => h(ContextModal, props),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Per-session open-state stores for the /context modal, plus the deferred
|
||||
* token-consume guards. Module-level so the state survives overlay remounts;
|
||||
* the trigger source (pick/enter) opens it, the overlay component subscribes
|
||||
* and renders. Implements the ObservableSnapshot pair the slot `hooks`
|
||||
* compartment binds as a `useContextModal` selector hook.
|
||||
*/
|
||||
|
||||
import type { TokenSpan } from './services'
|
||||
|
||||
export interface ModalStore {
|
||||
subscribe(listener: () => void): () => void
|
||||
getSnapshot(): boolean
|
||||
set(open: boolean): void
|
||||
}
|
||||
|
||||
const stores = new Map<string, ModalStore>()
|
||||
|
||||
export function modalStoreOf(sessionId: string): ModalStore {
|
||||
const existing = stores.get(sessionId)
|
||||
if (existing !== undefined) return existing
|
||||
let open = false
|
||||
const listeners = new Set<() => void>()
|
||||
const store: ModalStore = {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
getSnapshot: () => open,
|
||||
set(next) {
|
||||
if (next === open) return
|
||||
open = next
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
stores.set(sessionId, store)
|
||||
return store
|
||||
}
|
||||
|
||||
// ---- deferred token consumption -------------------------------------------
|
||||
// The `/context` token stays in the composer while the modal is open and is
|
||||
// consumed only when the modal closes. Each open path records the guard the
|
||||
// input shell understands (span CAS for menu picks, bare-token equality for
|
||||
// enter); the close path takes it and dispatches the scoped
|
||||
// `slash/input-consume-token` event. A stale guard (the user typed meanwhile)
|
||||
// fails soft inside the shell — the draft is left untouched.
|
||||
|
||||
export type ConsumeGuard =
|
||||
| { kind: 'span'; span: TokenSpan }
|
||||
| { kind: 'bare-token'; token: string }
|
||||
|
||||
const pendingConsume = new Map<string, ConsumeGuard>()
|
||||
|
||||
export function setPendingConsume(sessionId: string, guard: ConsumeGuard): void {
|
||||
pendingConsume.set(sessionId, guard)
|
||||
}
|
||||
|
||||
/** Take (and clear) the guard recorded by the open path, if any. */
|
||||
export function takePendingConsume(sessionId: string): ConsumeGuard | undefined {
|
||||
const guard = pendingConsume.get(sessionId)
|
||||
if (guard !== undefined) pendingConsume.delete(sessionId)
|
||||
return guard
|
||||
}
|
||||
+63
-1
@@ -25,7 +25,9 @@ export interface SlotRegistration {
|
||||
order: number
|
||||
/** optional dictionary namespace; the framework then synthesizes the `t` seat. */
|
||||
locale?: string
|
||||
label: () => string
|
||||
label?: () => string
|
||||
/** optional business face factory; a `hooks` compartment binds selector hooks onto props. */
|
||||
inject?: (sessionId: string) => unknown
|
||||
}
|
||||
|
||||
export interface SlotsService {
|
||||
@@ -58,3 +60,63 @@ export function timelineOf(value: unknown): ContextTimeline | null {
|
||||
if (value === null || value === undefined || typeof value !== 'object') return null
|
||||
return value as ContextTimeline
|
||||
}
|
||||
|
||||
// ---- /context command faces (framework `inputTriggers` service) ----
|
||||
|
||||
/** One menu candidate offered by a trigger source. */
|
||||
export interface TriggerCandidate {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Pick-moment snapshot of the trigger token span (draftRev CAS). */
|
||||
export interface TokenSpan {
|
||||
start: number
|
||||
end: number
|
||||
draftRev: number
|
||||
}
|
||||
|
||||
/** Everything a source receives on a menu pick. */
|
||||
export interface TriggerPick {
|
||||
candidate: TriggerCandidate
|
||||
session: { sessionId: string }
|
||||
position: string
|
||||
via: string
|
||||
span: TokenSpan
|
||||
}
|
||||
|
||||
/** The pick outcomes this plugin produces (see the framework's PickOutcome). */
|
||||
export type SourcePickOutcome = 'handled' | undefined
|
||||
|
||||
/**
|
||||
* The harness input-trigger service (`ctx.inputTriggers`), as far as this
|
||||
* plugin consumes it: registering one '/' source whose candidates, picks,
|
||||
* and enter adjudication all stay on the client.
|
||||
*/
|
||||
export interface InputTriggersFace {
|
||||
registerSource(src: {
|
||||
trigger: '/'
|
||||
name: string
|
||||
order?: number
|
||||
candidates(
|
||||
session: { sessionId: string },
|
||||
req: { query: string; position: string; signal: AbortSignal },
|
||||
): Promise<readonly TriggerCandidate[]>
|
||||
onPick(pick: TriggerPick): SourcePickOutcome
|
||||
matchEnter?(
|
||||
session: { sessionId: string },
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<SourcePickOutcome>
|
||||
}): () => void
|
||||
}
|
||||
|
||||
/** The session scope (`ctx.sessions.scope`), used to dispatch the scoped consume-token event. */
|
||||
export interface SessionScopeFace {
|
||||
bail(subject: unknown, event: string, payload: unknown): unknown
|
||||
}
|
||||
|
||||
/** The session runtime (`ctx.sessions`), as consumed here. */
|
||||
export interface SessionsFace {
|
||||
scope(id: string): SessionScopeFace | undefined
|
||||
}
|
||||
@@ -116,4 +116,12 @@ export const STYLES = [
|
||||
'.lc-nodes-more { color: var(--dsw-alias-label-secondary); padding: 3px 0; }',
|
||||
'.lc-empty { color: var(--dsw-alias-label-secondary); padding: 18px 0; text-align: center; }',
|
||||
'.lc-foot { color: var(--dsw-alias-label-secondary); font-size: 12px; margin-top: 4px; }',
|
||||
// ---- /context modal (centered dialog; escapes the composer anchor via fixed positioning) ----
|
||||
'.lc-modal-backdrop { position: fixed; inset: 0; z-index: 200; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; }',
|
||||
'.lc-modal-card { width: min(720px, calc(100vw - 48px)); max-height: min(82vh, 760px); overflow-y: auto; box-sizing: border-box; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); padding: 16px 18px 18px; color: var(--dsw-alias-label-primary); font-size: 13px; }',
|
||||
'.lc-modal-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }',
|
||||
'.lc-modal-title { font-weight: 600; font-size: 14px; }',
|
||||
'.lc-modal-close { margin-left: auto; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 6px; cursor: pointer; font-family: inherit; }',
|
||||
'.lc-modal-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }',
|
||||
'.lc-modal-trend { margin-top: 14px; }',
|
||||
].join('\n')
|
||||
+138
-6
@@ -98,13 +98,13 @@ const fakeCtx = {
|
||||
}
|
||||
pluginExports.apply(fakeCtx)
|
||||
|
||||
assert.equal(effects.length, 2, 'dictionaries + styles effects')
|
||||
assert.equal(effects.length, 3, 'dictionaries + styles + /context command effects')
|
||||
assert.deepEqual(localeRegistrations[0][0], 'dsh-context')
|
||||
assert.ok(localeRegistrations[0][1].zh && localeRegistrations[0][1].en, 'bilingual dicts')
|
||||
const styleTag = registered.get('dsh-context')
|
||||
assert.ok(styleTag, 'plugin-owned <style data-plugin="dsh-context"> injected')
|
||||
assert.ok(styleTag.textContent.includes('.lc-root'), 'styles content present')
|
||||
assert.equal(slotInjections.length, 1)
|
||||
assert.equal(slotInjections.length, 2, 'view tab + input overlay injections')
|
||||
assert.equal(slotInjections[0][0], 'conversation.view')
|
||||
const registeredOpts = slotInjections[0][1]() // slots.inject callback returns the register result
|
||||
assert.equal(registeredOpts.name, 'conversation.view')
|
||||
@@ -112,8 +112,65 @@ assert.equal(registeredOpts.id, 'context')
|
||||
assert.equal(registeredOpts.order, 20)
|
||||
assert.equal(typeof registeredOpts.label, 'function')
|
||||
assert.equal(registeredOpts.label(), '上下文', 'tab label localized')
|
||||
assert.equal(slotInjections[1][0], 'conversation.input.overlay')
|
||||
const overlayOpts = slotInjections[1][1]()
|
||||
assert.equal(overlayOpts.name, 'conversation.input.overlay')
|
||||
assert.equal(overlayOpts.id, 'context-modal')
|
||||
assert.ok(typeof overlayOpts.inject === 'function', 'overlay injects the modal store hook')
|
||||
const injected = overlayOpts.inject('s1')
|
||||
assert.equal(typeof injected.hooks.contextModal.getSnapshot, 'function', 'hooks compartment carries the modal store')
|
||||
assert.equal(injected.hooks.contextModal.getSnapshot(), false, 'modal closed initially')
|
||||
|
||||
console.log('✔ client bundle test passed (handoff, require table, dicts, styles, slot registration)')
|
||||
// ---- /context command: the plugin's own '/' trigger source ----
|
||||
// The first fake ctx has no inputTriggers service: the command effect must
|
||||
// stay inert (soft dependency), which the apply above already proved by not
|
||||
// throwing. Now apply again with the trigger service present.
|
||||
{
|
||||
const sources = []
|
||||
let localeDict = null
|
||||
const ctx3 = {
|
||||
get: (name) => name === 'inputTriggers'
|
||||
? { registerSource: (s) => { sources.push(s); return () => {} } }
|
||||
: undefined,
|
||||
effect: (fn) => { fn(); return () => {} },
|
||||
locale: {
|
||||
register: (ns, dicts) => { localeDict = dicts.zh; return () => {} },
|
||||
bind: () => (key, vars) => {
|
||||
let s = localeDict !== null && localeDict[key] !== undefined ? localeDict[key] : key
|
||||
if (vars) for (const k in vars) s = s.replace('{' + k + '}', String(vars[k]))
|
||||
return s
|
||||
},
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
slots: { inject: () => {}, register: () => ({}) },
|
||||
}
|
||||
pluginExports.apply(ctx3)
|
||||
assert.equal(sources.length, 1, 'one /context trigger source registered')
|
||||
const src = sources[0]
|
||||
assert.equal(src.trigger, '/')
|
||||
assert.equal(src.name, 'context')
|
||||
|
||||
// Candidates: leading-only, prefix-filtered, description localized.
|
||||
const req = (query, position = 'leading') => ({ query, position, signal: new AbortController().signal })
|
||||
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('')), [{ name: 'context', description: '查看上下文的构成和变化' }])
|
||||
assert.deepEqual((await src.candidates({ sessionId: 's1' }, req('cont'))).length, 1, 'prefix match')
|
||||
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('xyz')), [], 'non-prefix miss')
|
||||
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('', 'inline')), [], 'inline positions never offer the command')
|
||||
|
||||
// Menu pick: opens the session's modal and answers 'handled'; the token
|
||||
// stays in the composer while the modal is open (consumed on close).
|
||||
const outcome = src.onPick({ candidate: { name: 'context' }, session: { sessionId: 's1' }, position: 'leading', via: 'menu', span: { start: 0, end: 8, draftRev: 1 } })
|
||||
assert.equal(outcome, 'handled', 'menu pick is handled internally')
|
||||
assert.equal(injected.hooks.contextModal.getSnapshot(), true, 'menu pick opens the modal')
|
||||
|
||||
// Bare enter: also 'handled' (nothing is submitted, the draft keeps the
|
||||
// token until the modal closes).
|
||||
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/context', new AbortController().signal), 'handled', 'bare enter opens the modal without submitting')
|
||||
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/context now', new AbortController().signal), undefined, 'argued lines miss')
|
||||
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/compact', new AbortController().signal), undefined, 'other commands miss')
|
||||
}
|
||||
|
||||
console.log('✔ client bundle test passed (handoff, require table, dicts, styles, slot registration, /context command)')
|
||||
|
||||
// ---- chart render test: fixed-width bars, horizontal scroll, turn ranges ----
|
||||
// Materialize the bundle a second time with a STATEFUL fake React so the
|
||||
@@ -125,6 +182,7 @@ let currentHooks = null
|
||||
let hookCursor = 0
|
||||
const statefulReact = {
|
||||
createElement: (...args) => ({ kind: 'element', args }),
|
||||
useCallback: (fn) => fn,
|
||||
useState(init) {
|
||||
const i = hookCursor++
|
||||
const slots = currentHooks // captured by the setter: stable per fiber
|
||||
@@ -158,10 +216,19 @@ const requireStateful = (spec) => {
|
||||
const m2 = { exports: {} }
|
||||
const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc)
|
||||
|
||||
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.splitEst': '· category split is estimated', 'overview.ofWindow': 'tokens (~{p}%)', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})' }
|
||||
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.splitEst': '· category split is estimated', 'overview.ofWindow': 'tokens (~{p}%)', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close' }
|
||||
let viewComponent = null
|
||||
let modalComponent = null
|
||||
let modalSource = null
|
||||
const bailCalls = []
|
||||
const fakeCtx2 = {
|
||||
get: () => undefined,
|
||||
get: (name) => {
|
||||
if (name === 'inputTriggers') return { registerSource: (s) => { modalSource = s; return () => {} } }
|
||||
if (name === 'sessions') return {
|
||||
scope: (id) => id === 's1' ? { bail: (...args) => { bailCalls.push(args); return true } } : undefined,
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
effect: (fn) => { fn(); return () => {} },
|
||||
locale: {
|
||||
register: () => () => {},
|
||||
@@ -173,7 +240,11 @@ const fakeCtx2 = {
|
||||
},
|
||||
slots: {
|
||||
inject: (name, fn) => { fn() }, // the register call inside captures the component
|
||||
register: (opts, component) => { viewComponent = component; return opts },
|
||||
register: (opts, component) => {
|
||||
if (opts.name === 'conversation.view') viewComponent = component
|
||||
if (opts.name === 'conversation.input.overlay') modalComponent = component
|
||||
return opts
|
||||
},
|
||||
},
|
||||
}
|
||||
pluginExports2.apply(fakeCtx2)
|
||||
@@ -636,3 +707,64 @@ dataValue = snapshot
|
||||
tr = renderView()
|
||||
|
||||
console.log('✔ chart render test passed (context stats board, free window hover, fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times, event range labels, detail marker chip, overview actual)')
|
||||
|
||||
// ---- /context modal render: centered dialog with the same overview +
|
||||
// last-10-turn trend, driven by the modal store hook ----
|
||||
assert.ok(modalComponent !== null, 'overlay component captured')
|
||||
// 14 turns of growing totals; the modal must show exactly the last 10.
|
||||
const modalRequests = []
|
||||
for (let turn = 1; turn <= 14; turn++) {
|
||||
modalRequests.push({
|
||||
turn, step: 0, time: 1000 * turn, seq: turn * 10,
|
||||
system: 10, tools: 20, user: 10 * turn, inject: 0, assistant: 15, tool: 20, total: 65 + 10 * turn,
|
||||
})
|
||||
}
|
||||
const modalData = {
|
||||
...snapshot,
|
||||
current: { system: 10, tools: 20, user: 140, inject: 0, assistant: 15, tool: 20, total: 205 },
|
||||
requests: modalRequests,
|
||||
events: [{ seq: 95, time: 9500, kind: 'compaction', tokens: 50, count: 2 }],
|
||||
}
|
||||
let modalOpen = false
|
||||
const renderModal = () => evaluate(modalComponent({
|
||||
sessionId: 's1',
|
||||
useProjection: (key) => (key === 'contextTimeline' ? modalData : undefined),
|
||||
useContextModal: (sel) => sel(modalOpen),
|
||||
}))
|
||||
|
||||
assert.equal(renderModal(), null, 'modal renders nothing while closed')
|
||||
modalOpen = true
|
||||
const modalTree = renderModal()
|
||||
assert.equal(byClass(modalTree, 'lc-modal-backdrop').length, 1, 'centered backdrop rendered')
|
||||
const modalBars = byClass(modalTree, 'lc-bar')
|
||||
assert.equal(modalBars.length, 10, 'trend shows exactly the last 10 turns')
|
||||
// First visible bar is turn 5 (seq 50); turns 1-4 are cut.
|
||||
const modalTurns = byClass(modalTree, 'lc-turn')
|
||||
assert.deepEqual(modalTurns.map(t => t.args[2]), ['T5', 'T6', 'T7', 'T8', 'T9', 'T10', 'T11', 'T12', 'T13', 'T14'], 'turn ticks 5..14')
|
||||
// The compaction (seq 95) attaches to the first request after it (turn 10).
|
||||
assert.equal(byClass(modalTree, 'lc-bar-marker').length, 1, '✂ marker rides the turn after the compaction')
|
||||
const modalOverview = byClass(modalTree, 'lc-overview-num')[0]
|
||||
assert.match(textOf(modalOverview), /205/, 'modal headline falls back to the heuristic total (no prompt on last request)')
|
||||
assert.match(textOf(modalOverview), /tokens \(~0%\)/, 'percent against the 128k window')
|
||||
assert.equal(byClass(modalTree, 'lc-modal-trend').length, 1, 'trend section title present')
|
||||
const closeBtn = byClass(modalTree, 'lc-modal-close')[0]
|
||||
assert.equal(closeBtn.args[1]['aria-label'], 'Close', 'close button localized')
|
||||
|
||||
// Deferred token consumption: the enter path left `/context` in the draft
|
||||
// and recorded a bare-token guard; closing the modal dispatches the scoped
|
||||
// consume-token event through the session scope.
|
||||
assert.ok(modalSource !== null, 'trigger source registered on this instance')
|
||||
await modalSource.matchEnter({ sessionId: 's1' }, '/context', new AbortController().signal)
|
||||
const backdrop = byClass(modalTree, 'lc-modal-backdrop')[0]
|
||||
backdrop.args[1].onClick()
|
||||
assert.equal(bailCalls.length, 1, 'closing the modal dispatches consume-token')
|
||||
assert.equal(bailCalls[0][1], 'slash/input-consume-token')
|
||||
assert.deepEqual(bailCalls[0][2], { guard: { kind: 'bare-token', token: '/context' } }, 'enter-path guard consumed on close')
|
||||
// A second close without a prior open records nothing.
|
||||
backdrop.args[1].onClick()
|
||||
assert.equal(bailCalls.length, 1, 'no pending guard -> no dispatch')
|
||||
|
||||
modalOpen = false
|
||||
assert.equal(renderModal(), null, 'modal closes again')
|
||||
|
||||
console.log('✔ modal render test passed (open/close, last-10-turn window, ✂ marker, headline, localized chrome, deferred token consume on close)')
|
||||
Reference in new issue
Block a user