Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/local-runtime/src/api/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import type {
LocalTokenUsageStore,
LocalTurnDiffStore,
} from "../persistence/ports.js";
import { closeLocalRuntimeDb } from "../persistence/db.js";
import {
SqliteLocalCommunicationMessageStore,
SqliteLocalTokenUsageStore,
Expand Down Expand Up @@ -436,6 +437,14 @@ export class LocalRuntimeApiHost {
private readonly metricsReporter: LocalRuntimeApiHostOptions["metricsReporter"];
private readonly mavisCronAdapterProvider: LocalRuntimeApiHostOptions["mavisCronAdapterProvider"];
private readonly deleteAgentCronTasksOverride: LocalRuntimeApiHostOptions["deleteAgentCronTasks"];
/**
* The data directory this host's stores opened against, captured at
* construction. `configGetter()` stays live — a process may boot a second
* host (and move `MINIMAX_DATA_DIR`) before the first one closes — but the
* cached V1 SQLite connection this host owns is keyed by the construction
* time path, so that is the path close must release.
*/
private readonly persistenceDataDir: string;
private readonly fileGitChangesCoordinator =
createLocalFileApiGitChangesCoordinator();
constructor(options: LocalRuntimeApiHostOptions = {}) {
Expand Down Expand Up @@ -507,6 +516,7 @@ export class LocalRuntimeApiHost {
);
}
const runtimeDataDir = this.configGetter().dataDir;
this.persistenceDataDir = runtimeDataDir;
try {
ensureTrashScript(runtimeDataDir);
// Seeded alongside the trash script: the shim is the execution-layer
Expand Down Expand Up @@ -1477,6 +1487,16 @@ export class LocalRuntimeApiHost {
() => this.globalEvents.close(),
() => drainBackgroundTasks(this),
() => this.errorReporter?.close(),
// The V1 stores share one process-wide cached connection per data
// directory (`withLocalRuntimeDb`), and nothing else owns its lifetime.
// Closing it last — after the background tasks that may still write
// through it have drained — releases the SQLite file handles; on
// Windows an open handle keeps the data directory undeletable
// (`EBUSY`), which every isolated-data-dir consumer inherits. The
// construction-time path is the key: `configGetter()` reads the live
// environment, which a second host in the same process has already
// moved.
() => closeLocalRuntimeDb(this.persistenceDataDir),
];
for (const close of cleanups) {
try {
Expand Down
74 changes: 62 additions & 12 deletions packages/webui/src/client/features/composer/ui/slash-palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
//
// Uses three data pools (built-in commands, the plugin
// registry, and the skills resolver), sectioning, and four-rank filtering.
// The built-in list is intentionally scoped to the composer modes supported
// here; behavior fields keep the same record shape.
// The built-in list covers the composer modes plus every name the harness
// port's `runCommand` accepts; behavior fields keep the same record shape.
//
// See `packages/webui/docs/design-language.md` for the visual language and
// ADR 0009 for the shared vocabulary.
Expand All @@ -19,7 +19,12 @@ import {
import {
WebuiIconCommandCompact,
WebuiIconCommandGoal,
WebuiIconCommandHelp,
WebuiIconCommandPlan,
WebuiIconCommandStatus,
WebuiIconCommandUsage,
WebuiIconModel,
WebuiIconNewTask,
WebuiIconSites,
WebuiIconSkillAskMatt,
WebuiIconSkillCodebaseDesign,
Expand Down Expand Up @@ -83,17 +88,17 @@ export interface SlashCommandEntry extends WebuiSlashPaletteFields {
*
* Capability gating today is the static `supported` flag. Goal is backed by
* the goal operations, plan entry is backed by the send-message
* `plan-entry` intent, and compact is backed by the harness port's
* `runCommand` — `runtime/commands/runner.ts` routes `command === "compact"`
* into `requestCompaction`, and the operation is registered in
* `server/operation/operations.ts`. Commands without a WebUI path remain
* inert until their transport is wired.
* `plan-entry` intent, and every name in `WEBUI_RUN_COMMAND_NAMES` is backed
* by the harness port's `runCommand` — `runtime/commands/runner.ts` routes
* `help` to the descriptor list, `new` to `createSession`, `compact` to
* `requestCompaction`, `status` to `getAccountStatus`/`getSession`, `usage`
* to `getSessionUsage`, and `model` to `listModels`/`selectModel`; the
* operation is registered in `server/operation/operations.ts`. Commands
* without a WebUI path remain inert until their transport is wired.
*
* `compact` is the one run-command built-in exposed so far. The remaining
* names in `WEBUI_RUN_COMMAND_NAMES` (`help`, `new`, `status`, `usage`,
* `model`) are reachable at the transport layer but have no palette row, so
* a user cannot discover them; they need their own entries before they can
* be claimed as shipped.
* The run-command rows sit after the composer modes and keep the `/help`
* order of `WEBUI_RUN_COMMAND_NAMES`, so the palette and `/help` list the
* same vocabulary in the same order.
*/
export const WEBUI_BUILTIN_COMMANDS: readonly SlashCommandEntry[] = [
{
Expand All @@ -116,6 +121,24 @@ export const WEBUI_BUILTIN_COMMANDS: readonly SlashCommandEntry[] = [
icon: WebuiIconCommandPlan,
supported: true,
},
{
name: "help",
displayName: "help",
label: "帮助",
description: "查看可用斜杠命令列表",
source_type: -1,
icon: WebuiIconCommandHelp,
supported: true,
},
{
name: "new",
displayName: "new",
label: "新会话",
description: "在当前工作区新建一个会话",
source_type: -1,
icon: WebuiIconNewTask,
supported: true,
},
{
name: "compact",
displayName: "compact",
Expand All @@ -125,6 +148,33 @@ export const WEBUI_BUILTIN_COMMANDS: readonly SlashCommandEntry[] = [
icon: WebuiIconCommandCompact,
supported: true,
},
{
name: "status",
displayName: "status",
label: "状态",
description: "查看当前会话的账号、模型与工作区状态",
source_type: -1,
icon: WebuiIconCommandStatus,
supported: true,
},
{
name: "usage",
displayName: "usage",
label: "用量",
description: "查看当前会话的用量统计",
source_type: -1,
icon: WebuiIconCommandUsage,
supported: true,
},
{
name: "model",
displayName: "model",
label: "模型",
description: "查看模型列表;可附带名称切换模型,如 /model provider/model",
source_type: -1,
icon: WebuiIconModel,
supported: true,
},
];

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/webui/src/client/features/models/ui/ModelPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ export function WebuiModelPanel({
const active =
typeof recordedContext === "number" &&
recordedContext === value;
// The catalog's word for a window that bills as the heavier
// choice. The hint is keyed by the option's own value, so it can
// only ever annotate a size the row already shows — and a model
// whose catalog states none renders the size alone.
const higherUsage =
model?.contextWindowOptionHints?.[String(value)] ===
"higher_usage";
return (
<button
key={String(value)}
Expand All @@ -130,6 +137,11 @@ export function WebuiModelPanel({
onClick={() => onContextLimitChange(value)}
>
{formatContextWindow(value)}
{higherUsage ? (
<span className="webui-model-panel-context-option-hint">
较高用量
</span>
) : null}
</button>
);
})}
Expand Down
9 changes: 9 additions & 0 deletions packages/webui/src/client/features/models/ui/models.css
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@
display: inline-flex;
height: var(--spacing_24);
align-items: center;
gap: var(--spacing_4);
border: 1px solid var(--border_default);
border-radius: var(--radius_8);
background: transparent;
Expand All @@ -322,6 +323,14 @@
border-color: var(--border_accent);
color: var(--text_default_primary);
}
/* The catalog's word for the window that bills as the heavier choice. It is
* metadata about the size, not the size itself, so it is quieter than the
* number it rides with — and quieter in both themes, being a semantic role
* rather than a color. */
.webui-model-panel-context-option-hint {
color: var(--text_default_tertiary);
font-variant-numeric: normal;
}
/* ---- the thinking scale ----
*
* A blue rail up to the thumb, a white thumb ON it, and the stops as small
Expand Down
137 changes: 134 additions & 3 deletions packages/webui/src/client/features/transcript/ui/SessionTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
// flattening, history paging, streaming loader, and the embedded
// WebuiQuestionnaireResponse renderer for historical questionnaire answers.

import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, type ReactElement } from "react";
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { IconChevronUp } from "@tabler/icons-react";
import { ChatSkeleton } from "./TranscriptSkeletons.js";
import { ActivityIndicator, MessageAfterQueryStreamingPlaceholder, MessagePassiveLoadingPlaceholder } from "./ActivityIndicator.js";
import { TurnNavigator, type TurnSummary } from "./TurnNavigator.js";
import { TurnNavigator, type TurnNavigatorPreview, type TurnSummary } from "./TurnNavigator.js";
import { MessageItem } from "./MessageItem.js";
import { formatWebuiMessageTimestamp } from "../projection/message-timestamp.js";
import type { WebuiMessageActionCapabilities } from "../contracts/transcript-view.js";
Expand All @@ -29,6 +29,7 @@ import {
projectWebuiTranscriptMessages,
projectWebuiQueryDurations,
projectWebuiProcessSegments,
type WebuiTranscriptGroup,
} from "../projection/transcript-projection.js";
import { projectWebuiTranscriptMessage } from "../projection/message-projection.js";
import { projectWebuiTranscriptView } from "../projection/transcript-view-model.js";
Expand All @@ -45,6 +46,107 @@ import {

const EMPTY_TRANSCRIPT_PAGE: WebuiClientMessagePage = {};

// --- TurnNavigator integration ------------------------------------------------
//
// `TurnNavigator` is the presentational seam and defers hover, scroll and jump
// to this integrator. The helpers below are that integration's pure halves,
// exported so the node-environment suite can assert them without a DOM: which
// message a tick anchors to, what one turn's hover preview shows, and what a
// reveal does to its target. The mouse wiring itself (setState on hover,
// querySelector on activate) stays in the component and is the boundary SSR
// cannot reach.

/** Every rendered message root carries `data-message-id` (MessageItem), so a
* tick reuses that attribute as its anchor instead of a second anchor
* system. Quotes and backslashes are escaped so a message id cannot close
* the attribute selector. */
export function turnNavigatorAnchorSelector(turnId: string): string {
return `[data-message-id="${turnId.replace(/["\\]/g, "\\$&")}"]`;
}

export const TURN_NAVIGATOR_FLASH_CLASS = "webui-turn-jump-flash";
const TURN_NAVIGATOR_FLASH_MS = 1200;

export interface WebuiTurnRevealTarget {
scrollIntoView(options?: ScrollIntoViewOptions): void;
readonly classList: { add(token: string): void; remove(token: string): void };
}

/** Reveal one turn after a tick activation: center it in the transcript
* viewport and flash it so the destination is findable. The class is removed
* after `flashMs` so the highlight stays transient. */
export function revealTurnNavigatorAnchor(
target: WebuiTurnRevealTarget,
flashMs = TURN_NAVIGATOR_FLASH_MS,
): void {
target.scrollIntoView({ block: "center", behavior: "smooth" });
target.classList.add(TURN_NAVIGATOR_FLASH_CLASS);
setTimeout(() => target.classList.remove(TURN_NAVIGATOR_FLASH_CLASS), flashMs);
}

const TURN_NAVIGATOR_RESPONSE_MAX_CHARS = 120;

/** The preview's one-line answer summary: the first non-empty line, capped. */
function firstTurnSummaryLine(text: string): string {
const line = text.split("\n").find((candidate) => candidate.trim().length > 0) ?? "";
return line.length > TURN_NAVIGATOR_RESPONSE_MAX_CHARS
? `${line.slice(0, TURN_NAVIGATOR_RESPONSE_MAX_CHARS - 1)}…`
: line;
}

function hasAssistantItems(group: WebuiTranscriptGroup): boolean {
return group.items.some((item) => item.kind === "assistant");
}

function turnUserQuery(
groups: readonly WebuiTranscriptGroup[],
fromIndex: number,
step: 1 | -1,
): string | undefined {
for (let index = fromIndex; index >= 0 && index < groups.length; index += step) {
const userItem = groups[index]?.items.find(
(item): item is Extract<WebuiTranscriptItem, { text: string }> => item.kind === "user",
);
if (userItem) return userItem.text;
}
return undefined;
}

/** The hover preview for one tick: the turn's 1-based number among the
* assistant blocks the strip lists, its first answer line as the summary, the
* group's own timestamp, framed by the user queries on both sides. Returns
* null when the id is not one of the assistant turns the strip lists — the
* numbering and membership mirror `projectTurnRows`. */
export function projectTurnNavigatorPreview(input: {
readonly turnId: string;
readonly groups: readonly WebuiTranscriptGroup[];
}): TurnNavigatorPreview | null {
const assistantGroups = input.groups.filter(hasAssistantItems);
const tickIndex = assistantGroups.findIndex((group) => group.messageId === input.turnId);
if (tickIndex < 0) return null;
const group = assistantGroups[tickIndex]!;
const groupIndex = input.groups.indexOf(group);
const answer = group.items.find(
(item): item is Extract<WebuiTranscriptItem, { text: string }> =>
item.kind === "assistant" && item.text.trim().length > 0,
);
const time = formatWebuiMessageTimestamp(
group.items.find((item) => typeof item.timestamp === "number")?.timestamp,
);
const previousQuery = groupIndex > 0 ? turnUserQuery(input.groups, groupIndex - 1, -1) : undefined;
const nextQuery =
groupIndex >= 0 && groupIndex < input.groups.length - 1
? turnUserQuery(input.groups, groupIndex + 1, 1)
: undefined;
return {
title: `第 ${tickIndex + 1} 轮`,
...(time ? { time } : {}),
...(previousQuery ? { previousQuery } : {}),
...(nextQuery ? { nextQuery } : {}),
...(answer ? { response: firstTurnSummaryLine(answer.text) } : {}),
};
}

export function WebuiSessionTranscript({
sessionId,
initialMessages,
Expand Down Expand Up @@ -158,6 +260,27 @@ export function WebuiSessionTranscript({
turnLive,
contentKey: items,
});
// The navigator's tick interactions: hover decides which turn the preview
// card describes, activation jumps the viewport to that turn's message root.
// `TurnNavigator` renders; this integrator owns the state and the DOM.
const [hoveredTurnId, setHoveredTurnId] = useState<string>();
const navigatorPreview = useMemo(
() =>
hoveredTurnId
? projectTurnNavigatorPreview({ turnId: hoveredTurnId, groups })
: null,
[groups, hoveredTurnId],
);
const activateNavigatorTick = (turnId: string) => {
const anchor = transcriptRef.current?.querySelector<HTMLElement>(
turnNavigatorAnchorSelector(turnId),
);
if (!anchor) return;
// 跳转是读者主动离开贴底跟随:先交还控制权,否则后续内容增长会把
// 视口从落点拽回底部(loadOlder 出于同一原因先释放)。
releaseAutoFollow();
revealTurnNavigatorAnchor(anchor);
};
const loadOlder = visiblePage.hasMore && visiblePage.nextCursor
? () => {
if (loading) return;
Expand Down Expand Up @@ -433,7 +556,15 @@ export function WebuiSessionTranscript({
</div>
) : null}
</div>
<TurnNavigator turns={turns} />
<TurnNavigator
turns={turns}
preview={navigatorPreview}
onTickHover={(turnId) => setHoveredTurnId(turnId)}
onTickLeave={(turnId) =>
setHoveredTurnId((current) => (current === turnId ? undefined : current))
}
onTickActivate={(turnId) => activateNavigatorTick(turnId)}
/>
</section>
);
}
Loading
Loading