mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Squashed from 8 commits on zero/acp-test: - feat(agent-mode): introduce Agent Mode feature (squashed) - Fix eslint - hide add context button - chore: ban parent-relative imports and rewrite existing ones to @/ aliases - wip(agent-mode): skills management + slash command revamp - fix(agent-mode): initialize SkillManager before preload probes - fix(build): align svgr jsxRuntime with tsconfig classic jsx - fix(test): stub ItemView/WorkspaceLeaf in ChatSingleMessage obsidian mock
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import type {
|
|
BackendConfigOption,
|
|
BackendDescriptor,
|
|
RawModelState,
|
|
RawModeState,
|
|
BackendState,
|
|
CopilotMode,
|
|
EffortOption,
|
|
ModeApplySpec,
|
|
ModeMapping,
|
|
ModeOption,
|
|
ModelEntry,
|
|
ModelSelection,
|
|
ModelState,
|
|
} from "@/agentMode/session/types";
|
|
|
|
const CANONICAL_ORDER: CopilotMode[] = ["default", "plan", "auto"];
|
|
const CANONICAL_LABELS: Record<CopilotMode, string> = {
|
|
default: "Default",
|
|
plan: "Plan",
|
|
auto: "Auto",
|
|
};
|
|
|
|
/**
|
|
* Backend-supplied raw catalogs — what a backend has after `session/new`
|
|
* (or after a wire-format → neutral conversion). Backends call
|
|
* `translateBackendState` with this and the descriptor to produce the
|
|
* normalized `BackendState` consumers see.
|
|
*/
|
|
export interface BackendStateInputs {
|
|
models: RawModelState | null;
|
|
modes: RawModeState | null;
|
|
configOptions: BackendConfigOption[] | null;
|
|
}
|
|
|
|
/**
|
|
* Project a backend's neutral catalogs onto the unified `BackendState`
|
|
* consumers use. Pure function — depends only on the inputs and the
|
|
* descriptor's wire codec + mode mapping. Re-running on every state
|
|
* mutation is cheap.
|
|
*/
|
|
export function translateBackendState(
|
|
inputs: BackendStateInputs,
|
|
descriptor: BackendDescriptor
|
|
): BackendState {
|
|
return {
|
|
model: translateModel(inputs, descriptor),
|
|
mode: translateMode(inputs, descriptor),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Look up the rich `ModelEntry` for a given `baseModelId` in a
|
|
* `ModelState`. Provided as a tiny session-layer helper so consumers
|
|
* that need name/description/effortOptions for the current selection
|
|
* don't open-code the lookup.
|
|
*/
|
|
export function findModelEntry(
|
|
state: ModelState | null | undefined,
|
|
baseModelId: string
|
|
): ModelEntry | undefined {
|
|
return state?.availableModels.find((e) => e.baseModelId === baseModelId);
|
|
}
|
|
|
|
function translateModel(
|
|
inputs: BackendStateInputs,
|
|
descriptor: BackendDescriptor
|
|
): ModelState | null {
|
|
const modelState = inputs.models;
|
|
if (!modelState) return null;
|
|
|
|
// Group advertised wire ids by baseModelId, preserving first-seen order.
|
|
type Group = {
|
|
baseModelId: string;
|
|
provider: string | null;
|
|
name: string;
|
|
description?: string;
|
|
/** Per-effort entries for suffix-style backends (decoded from wire ids). */
|
|
variants: { effort: string | null; wireId: string }[];
|
|
};
|
|
const groups: Group[] = [];
|
|
const groupByBase = new Map<string, Group>();
|
|
|
|
for (const m of modelState.availableModels) {
|
|
const decoded = descriptor.wire.decode(m.modelId);
|
|
const baseId = decoded.selection.baseModelId;
|
|
const existing = groupByBase.get(baseId);
|
|
const group: Group =
|
|
existing ??
|
|
(() => {
|
|
const fresh: Group = {
|
|
baseModelId: baseId,
|
|
provider: decoded.provider,
|
|
name: m.name,
|
|
description: m.description ?? undefined,
|
|
variants: [],
|
|
};
|
|
groupByBase.set(baseId, fresh);
|
|
groups.push(fresh);
|
|
return fresh;
|
|
})();
|
|
if (!group.variants.some((v) => v.effort === decoded.selection.effort)) {
|
|
group.variants.push({ effort: decoded.selection.effort, wireId: m.modelId });
|
|
}
|
|
}
|
|
|
|
const availableModels: ModelEntry[] = groups.map((g) => ({
|
|
baseModelId: g.baseModelId,
|
|
// Strip a trailing `(<effort>)` only when the group has multiple
|
|
// variants — those rows render an effort dropdown, so the suffix
|
|
// becomes redundant. The recognized vocabulary comes from the
|
|
// variants themselves (decoded by the descriptor's wire codec),
|
|
// so backends own their effort tokens — we don't duplicate them.
|
|
name: g.variants.length >= 2 ? stripEffortSuffix(g.name, g.variants) : g.name,
|
|
description: g.description,
|
|
provider: g.provider,
|
|
effortOptions: deriveEffortOptions(g, descriptor),
|
|
}));
|
|
|
|
// Build current. The agent's currentModelId may decompose into a
|
|
// baseModelId not present in availableModels (rare; stale probe state).
|
|
// In that case, synthesize an entry and append so the current selection
|
|
// always has a corresponding entry in `availableModels`.
|
|
const decodedCurrent = descriptor.wire.decode(modelState.currentModelId);
|
|
const currentBaseId = decodedCurrent.selection.baseModelId;
|
|
let currentEntry = availableModels.find((e) => e.baseModelId === currentBaseId);
|
|
if (!currentEntry) {
|
|
const synthEffortOptions = descriptor.wire.effortConfigFor
|
|
? optionsFromConfigOption(descriptor.wire.effortConfigFor(currentBaseId))
|
|
: [];
|
|
currentEntry = {
|
|
baseModelId: currentBaseId,
|
|
name: currentBaseId,
|
|
provider: decodedCurrent.provider,
|
|
effortOptions: synthEffortOptions,
|
|
};
|
|
availableModels.push(currentEntry);
|
|
}
|
|
|
|
const current: ModelSelection = {
|
|
baseModelId: currentEntry.baseModelId,
|
|
effort: resolveCurrentEffort(
|
|
decodedCurrent.selection.effort,
|
|
currentEntry,
|
|
descriptor,
|
|
inputs.configOptions
|
|
),
|
|
};
|
|
|
|
return { current, availableModels };
|
|
}
|
|
|
|
function deriveEffortOptions(
|
|
group: { variants: { effort: string | null; wireId: string }[]; baseModelId: string },
|
|
descriptor: BackendDescriptor
|
|
): EffortOption[] {
|
|
// Suffix-style: ≥2 variants for the same base means we have an effort
|
|
// dimension encoded in the wire id.
|
|
if (group.variants.length >= 2) {
|
|
const options: EffortOption[] = [];
|
|
const hasBare = group.variants.some((v) => v.effort === null);
|
|
if (hasBare) options.push({ value: null, label: "default" });
|
|
for (const v of group.variants) {
|
|
if (v.effort === null) continue;
|
|
options.push({ value: v.effort, label: v.effort.toLowerCase() });
|
|
}
|
|
return options;
|
|
}
|
|
// Descriptor-style: ask the codec for a per-model effort option.
|
|
if (descriptor.wire.effortConfigFor) {
|
|
const opt = descriptor.wire.effortConfigFor(group.baseModelId);
|
|
return optionsFromConfigOption(opt);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function optionsFromConfigOption(opt: BackendConfigOption | null): EffortOption[] {
|
|
if (!opt || opt.type !== "select") return [];
|
|
const flat: { value: string; name: string }[] = [];
|
|
for (const entry of opt.options) {
|
|
if ("options" in entry) {
|
|
for (const inner of entry.options) flat.push({ value: inner.value, name: inner.name });
|
|
} else {
|
|
flat.push({ value: entry.value, name: entry.name });
|
|
}
|
|
}
|
|
return flat.map((o) => ({ value: o.value, label: (o.name || o.value).toLowerCase() }));
|
|
}
|
|
|
|
/**
|
|
* Resolve `current.effort` for the active selection. For suffix-style
|
|
* (effort encoded in wire id), the decoded value wins. For descriptor-
|
|
* style, prefer the live `currentValue` from `inputs.configOptions`
|
|
* (matched by the spec's id) so user effort changes round-trip; fall
|
|
* back to the spec's default when the agent hasn't reported one yet.
|
|
* The result is snapped to an option that exists in
|
|
* `currentEntry.effortOptions`; otherwise null.
|
|
*/
|
|
function resolveCurrentEffort(
|
|
decodedEffort: string | null,
|
|
currentEntry: ModelEntry,
|
|
descriptor: BackendDescriptor,
|
|
configOptions: BackendConfigOption[] | null
|
|
): string | null {
|
|
let candidate: string | null = decodedEffort;
|
|
if (candidate === null && descriptor.wire.effortConfigFor) {
|
|
const spec = descriptor.wire.effortConfigFor(currentEntry.baseModelId);
|
|
if (spec && spec.type === "select") {
|
|
const live = configOptions?.find((c) => c.id === spec.id);
|
|
const liveValue = live && live.type === "select" ? String(live.currentValue) : null;
|
|
candidate = liveValue ?? (spec.currentValue != null ? String(spec.currentValue) : null);
|
|
}
|
|
}
|
|
if (candidate === null) {
|
|
return currentEntry.effortOptions.some((o) => o.value === null) ? null : null;
|
|
}
|
|
if (currentEntry.effortOptions.some((o) => o.value === candidate)) return candidate;
|
|
return null;
|
|
}
|
|
|
|
function translateMode(
|
|
inputs: BackendStateInputs,
|
|
descriptor: BackendDescriptor
|
|
): BackendState["mode"] {
|
|
const mapping = descriptor.getModeMapping?.(inputs.modes, inputs.configOptions);
|
|
if (!mapping) return null;
|
|
if (mapping.kind === "setMode") return translateSetModeMapping(mapping, inputs.modes);
|
|
return translateConfigOptionModeMapping(mapping, inputs.configOptions);
|
|
}
|
|
|
|
function translateSetModeMapping(
|
|
mapping: ModeMapping,
|
|
modeState: RawModeState | null
|
|
): BackendState["mode"] {
|
|
if (!modeState) return null;
|
|
const advertised = new Set(modeState.availableModes.map((m) => m.id));
|
|
const options: ModeOption[] = [];
|
|
const apply: Partial<Record<CopilotMode, ModeApplySpec>> = {};
|
|
for (const value of CANONICAL_ORDER) {
|
|
const native = mapping.canonical[value];
|
|
if (!native || !advertised.has(native)) continue;
|
|
options.push({ value, label: CANONICAL_LABELS[value] });
|
|
apply[value] = { kind: "setMode", nativeId: native };
|
|
}
|
|
if (options.length === 0) return null;
|
|
const current = reverseProjectMode(mapping.canonical, modeState.currentModeId, options);
|
|
return { current, options, apply };
|
|
}
|
|
|
|
function translateConfigOptionModeMapping(
|
|
mapping: ModeMapping,
|
|
configOptions: BackendConfigOption[] | null
|
|
): BackendState["mode"] {
|
|
if (!mapping.configId || !configOptions) return null;
|
|
const opt = configOptions.find((o) => o.id === mapping.configId);
|
|
if (!opt || opt.type !== "select") return null;
|
|
|
|
const flatValues = new Set<string>();
|
|
for (const entry of opt.options) {
|
|
if ("options" in entry) {
|
|
for (const inner of entry.options) flatValues.add(inner.value);
|
|
} else {
|
|
flatValues.add(entry.value);
|
|
}
|
|
}
|
|
|
|
const options: ModeOption[] = [];
|
|
const apply: Partial<Record<CopilotMode, ModeApplySpec>> = {};
|
|
for (const value of CANONICAL_ORDER) {
|
|
const native = mapping.canonical[value];
|
|
if (!native || !flatValues.has(native)) continue;
|
|
options.push({ value, label: CANONICAL_LABELS[value] });
|
|
apply[value] = { kind: "setConfigOption", configId: opt.id, value: native };
|
|
}
|
|
if (options.length === 0) return null;
|
|
|
|
const current = reverseProjectMode(mapping.canonical, String(opt.currentValue), options);
|
|
return { current, options, apply };
|
|
}
|
|
|
|
/**
|
|
* Reverse-project a native mode id back to a canonical Copilot mode.
|
|
* Returns `null` when the agent is sitting in a mode the descriptor
|
|
* doesn't map (e.g. Claude's `acceptEdits` — intentionally hidden).
|
|
*/
|
|
function reverseProjectMode(
|
|
canonical: ModeMapping["canonical"],
|
|
nativeId: string,
|
|
visibleOptions: ModeOption[]
|
|
): CopilotMode | null {
|
|
const visible = new Set(visibleOptions.map((o) => o.value));
|
|
for (const opt of CANONICAL_ORDER) {
|
|
if (!visible.has(opt)) continue;
|
|
if (canonical[opt] === nativeId) return opt;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function stripEffortSuffix(name: string, variants: { effort: string | null }[]): string {
|
|
const m = name.match(/^(.*?)\s*\(([^()]+)\)\s*$/);
|
|
if (!m) return name;
|
|
const efforts = new Set(variants.flatMap((v) => (v.effort ? [v.effort.toLowerCase()] : [])));
|
|
if (!efforts.has(m[2].toLowerCase())) return name;
|
|
return m[1].trim();
|
|
}
|
|
|
|
/**
|
|
* Stable signature of the model slice of a `BackendState`. Used by the
|
|
* model+effort picker hook to invalidate its memo only on model-relevant
|
|
* changes.
|
|
*/
|
|
export function modelStateSignature(state: BackendState | null): string {
|
|
const m = state?.model;
|
|
if (!m) return "";
|
|
return [
|
|
m.current.baseModelId,
|
|
m.current.effort ?? "",
|
|
m.availableModels
|
|
.map(
|
|
(e) =>
|
|
`${e.baseModelId}:${e.provider ?? ""}:${e.effortOptions
|
|
.map((o) => o.value ?? "_")
|
|
.join("|")}`
|
|
)
|
|
.join(","),
|
|
].join("/");
|
|
}
|
|
|
|
/**
|
|
* Stable signature of the mode slice of a `BackendState`. Used by the mode
|
|
* picker hook to invalidate its memo only on mode-relevant changes. Includes
|
|
* each option's apply-spec kind so capability flips (`setMode` ↔
|
|
* `setConfigOption`) propagate.
|
|
*/
|
|
export function modeStateSignature(state: BackendState | null): string {
|
|
const md = state?.mode;
|
|
if (!md) return "";
|
|
const apply = md.options.map((o) => `${o.value}:${md.apply[o.value]?.kind ?? ""}`).join(",");
|
|
return `${md.current ?? ""}|${apply}`;
|
|
}
|
|
|
|
/**
|
|
* Stable signature of a normalized `BackendState`. Used by the preloader
|
|
* to skip notifying listeners on no-op updates.
|
|
*/
|
|
export function backendStateSignature(state: BackendState | null): string {
|
|
if (!state) return "";
|
|
return `${modelStateSignature(state)}#${modeStateSignature(state)}`;
|
|
}
|