Web UI: add Phase 3 UX improvements
- Add onboarding wizard progress indicator with step visualization - Add skills quick-toggle panel in topbar for fast enable/disable - Add config presets system (Fast Chat, Coding Agent, Voice Assistant, Privacy First) - Add config view levels (Basic/Standard/Advanced) to reduce complexity - Add voice input button for mobile WebChat using Web Speech API - Improve mobile responsiveness (safe-area insets, 44px touch targets, bottom sheets)
This commit is contained in:
parent
1755ba52df
commit
e7728ff037
@ -18,6 +18,12 @@ Status: beta.
|
||||
- Refactor: add centralized error utilities with MoltbotError base class.
|
||||
- Refactor: extract EmbeddingService from memory manager (reduces manager.ts by 25%).
|
||||
- Docs: add large file analysis for refactoring opportunities.
|
||||
- Web UI: add onboarding wizard progress indicator with step visualization.
|
||||
- Web UI: add skills quick-toggle panel in topbar for fast enable/disable.
|
||||
- Web UI: add config presets system (Fast Chat, Coding Agent, Voice Assistant, Privacy First).
|
||||
- Web UI: add config view levels (Basic/Standard/Advanced) to reduce complexity.
|
||||
- Web UI: add voice input button for mobile WebChat using Web Speech API.
|
||||
- Web UI: improve mobile responsiveness (safe-area insets, 44px touch targets, bottom sheets).
|
||||
- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev.
|
||||
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
|
||||
- macOS: finish Moltbot app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3.
|
||||
|
||||
@ -21,5 +21,6 @@ export const makePrompter = (overrides: Partial<WizardPrompter> = {}): WizardPro
|
||||
text: vi.fn(async () => "") as WizardPrompter["text"],
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
setStepProgress: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@ import { createCliProgress } from "../cli/progress.js";
|
||||
import { note as emitNote } from "../terminal/note.js";
|
||||
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import type { WizardProgress, WizardPrompter } from "./prompts.js";
|
||||
import type { StepProgress, WizardProgress, WizardPrompter } from "./prompts.js";
|
||||
import { WizardCancelledError } from "./prompts.js";
|
||||
|
||||
function guardCancel<T>(value: T | symbol): T {
|
||||
@ -26,6 +26,21 @@ function guardCancel<T>(value: T | symbol): T {
|
||||
}
|
||||
|
||||
export function createClackPrompter(): WizardPrompter {
|
||||
let stepProgress: StepProgress | null = null;
|
||||
|
||||
const formatStepIndicator = (): string | null => {
|
||||
if (!stepProgress) return null;
|
||||
const { current, total, label } = stepProgress;
|
||||
return `Step ${current} of ${total}: ${label}`;
|
||||
};
|
||||
|
||||
const showStepIndicator = () => {
|
||||
const indicator = formatStepIndicator();
|
||||
if (indicator) {
|
||||
emitNote(theme.muted(indicator), "Progress");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
intro: async (title) => {
|
||||
intro(stylePromptTitle(title) ?? title);
|
||||
@ -36,8 +51,9 @@ export function createClackPrompter(): WizardPrompter {
|
||||
note: async (message, title) => {
|
||||
emitNote(message, title);
|
||||
},
|
||||
select: async (params) =>
|
||||
guardCancel(
|
||||
select: async (params) => {
|
||||
showStepIndicator();
|
||||
return guardCancel(
|
||||
await select({
|
||||
message: stylePromptMessage(params.message),
|
||||
options: params.options.map((opt) => {
|
||||
@ -46,9 +62,11 @@ export function createClackPrompter(): WizardPrompter {
|
||||
}) as Option<(typeof params.options)[number]["value"]>[],
|
||||
initialValue: params.initialValue,
|
||||
}),
|
||||
),
|
||||
multiselect: async (params) =>
|
||||
guardCancel(
|
||||
);
|
||||
},
|
||||
multiselect: async (params) => {
|
||||
showStepIndicator();
|
||||
return guardCancel(
|
||||
await multiselect({
|
||||
message: stylePromptMessage(params.message),
|
||||
options: params.options.map((opt) => {
|
||||
@ -57,23 +75,28 @@ export function createClackPrompter(): WizardPrompter {
|
||||
}) as Option<(typeof params.options)[number]["value"]>[],
|
||||
initialValues: params.initialValues,
|
||||
}),
|
||||
),
|
||||
text: async (params) =>
|
||||
guardCancel(
|
||||
);
|
||||
},
|
||||
text: async (params) => {
|
||||
showStepIndicator();
|
||||
return guardCancel(
|
||||
await text({
|
||||
message: stylePromptMessage(params.message),
|
||||
initialValue: params.initialValue,
|
||||
placeholder: params.placeholder,
|
||||
validate: params.validate,
|
||||
}),
|
||||
),
|
||||
confirm: async (params) =>
|
||||
guardCancel(
|
||||
);
|
||||
},
|
||||
confirm: async (params) => {
|
||||
showStepIndicator();
|
||||
return guardCancel(
|
||||
await confirm({
|
||||
message: stylePromptMessage(params.message),
|
||||
initialValue: params.initialValue,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
progress: (label: string): WizardProgress => {
|
||||
const spin = spinner();
|
||||
spin.start(theme.accent(label));
|
||||
@ -94,5 +117,8 @@ export function createClackPrompter(): WizardPrompter {
|
||||
},
|
||||
};
|
||||
},
|
||||
setStepProgress: (progress) => {
|
||||
stepProgress = progress;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -97,6 +97,7 @@ describe("runOnboardingWizard", () => {
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
setStepProgress: vi.fn(),
|
||||
};
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
@ -140,6 +141,7 @@ describe("runOnboardingWizard", () => {
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
setStepProgress: vi.fn(),
|
||||
};
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
@ -191,6 +193,7 @@ describe("runOnboardingWizard", () => {
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
setStepProgress: vi.fn(),
|
||||
};
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
@ -246,6 +249,7 @@ describe("runOnboardingWizard", () => {
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
setStepProgress: vi.fn(),
|
||||
};
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
@ -297,6 +301,7 @@ describe("runOnboardingWizard", () => {
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
setStepProgress: vi.fn(),
|
||||
};
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
|
||||
@ -41,7 +41,29 @@ import { resolveUserPath } from "../utils.js";
|
||||
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
|
||||
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
|
||||
import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js";
|
||||
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
|
||||
import { WizardCancelledError, type StepProgress, type WizardPrompter } from "./prompts.js";
|
||||
|
||||
/**
|
||||
* Step definitions for wizard flows.
|
||||
* QuickStart has fewer steps since it skips workspace/gateway prompts.
|
||||
*/
|
||||
const QUICKSTART_STEPS = {
|
||||
SECURITY: { current: 1, total: 5, label: "Security" },
|
||||
AUTH: { current: 2, total: 5, label: "Model Provider" },
|
||||
CHANNELS: { current: 3, total: 5, label: "Channels" },
|
||||
SKILLS: { current: 4, total: 5, label: "Skills" },
|
||||
FINALIZE: { current: 5, total: 5, label: "Finalize" },
|
||||
} as const satisfies Record<string, StepProgress>;
|
||||
|
||||
const ADVANCED_STEPS = {
|
||||
SECURITY: { current: 1, total: 7, label: "Security" },
|
||||
CONFIG: { current: 2, total: 7, label: "Configuration" },
|
||||
AUTH: { current: 3, total: 7, label: "Model Provider" },
|
||||
GATEWAY: { current: 4, total: 7, label: "Gateway" },
|
||||
CHANNELS: { current: 5, total: 7, label: "Channels" },
|
||||
SKILLS: { current: 6, total: 7, label: "Skills" },
|
||||
FINALIZE: { current: 7, total: 7, label: "Finalize" },
|
||||
} as const satisfies Record<string, StepProgress>;
|
||||
|
||||
async function requireRiskAcknowledgement(params: {
|
||||
opts: OnboardOptions;
|
||||
@ -91,6 +113,9 @@ export async function runOnboardingWizard(
|
||||
) {
|
||||
printWizardHeader(runtime);
|
||||
await prompter.intro("Moltbot onboarding");
|
||||
|
||||
// Security acknowledgement (Step 1 for both flows)
|
||||
prompter.setStepProgress(QUICKSTART_STEPS.SECURITY);
|
||||
await requireRiskAcknowledgement({ opts, prompter });
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
@ -151,6 +176,11 @@ export async function runOnboardingWizard(
|
||||
flow = "advanced";
|
||||
}
|
||||
|
||||
// Config handling step (Advanced flow only)
|
||||
if (snapshot.exists && flow === "advanced") {
|
||||
prompter.setStepProgress(ADVANCED_STEPS.CONFIG);
|
||||
}
|
||||
|
||||
if (snapshot.exists) {
|
||||
await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected");
|
||||
|
||||
@ -350,6 +380,9 @@ export async function runOnboardingWizard(
|
||||
},
|
||||
};
|
||||
|
||||
// Auth/model step
|
||||
prompter.setStepProgress(flow === "quickstart" ? QUICKSTART_STEPS.AUTH : ADVANCED_STEPS.AUTH);
|
||||
|
||||
const authStore = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
@ -390,6 +423,11 @@ export async function runOnboardingWizard(
|
||||
|
||||
await warnIfModelConfigLooksOff(nextConfig, prompter);
|
||||
|
||||
// Gateway configuration step (only shows indicator for Advanced flow)
|
||||
if (flow === "advanced") {
|
||||
prompter.setStepProgress(ADVANCED_STEPS.GATEWAY);
|
||||
}
|
||||
|
||||
const gateway = await configureGatewayForOnboarding({
|
||||
flow,
|
||||
baseConfig,
|
||||
@ -402,6 +440,11 @@ export async function runOnboardingWizard(
|
||||
nextConfig = gateway.nextConfig;
|
||||
const settings = gateway.settings;
|
||||
|
||||
// Channels step
|
||||
prompter.setStepProgress(
|
||||
flow === "quickstart" ? QUICKSTART_STEPS.CHANNELS : ADVANCED_STEPS.CHANNELS,
|
||||
);
|
||||
|
||||
if (opts.skipChannels ?? opts.skipProviders) {
|
||||
await prompter.note("Skipping channel setup.", "Channels");
|
||||
} else {
|
||||
@ -426,6 +469,9 @@ export async function runOnboardingWizard(
|
||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||
});
|
||||
|
||||
// Skills step
|
||||
prompter.setStepProgress(flow === "quickstart" ? QUICKSTART_STEPS.SKILLS : ADVANCED_STEPS.SKILLS);
|
||||
|
||||
if (opts.skipSkills) {
|
||||
await prompter.note("Skipping skills setup.", "Skills");
|
||||
} else {
|
||||
@ -438,6 +484,11 @@ export async function runOnboardingWizard(
|
||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||
await writeConfigFile(nextConfig);
|
||||
|
||||
// Finalize step
|
||||
prompter.setStepProgress(
|
||||
flow === "quickstart" ? QUICKSTART_STEPS.FINALIZE : ADVANCED_STEPS.FINALIZE,
|
||||
);
|
||||
|
||||
await finalizeOnboardingWizard({
|
||||
flow,
|
||||
opts,
|
||||
|
||||
@ -33,6 +33,15 @@ export type WizardProgress = {
|
||||
stop: (message?: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks overall wizard progress (step X of Y).
|
||||
*/
|
||||
export type StepProgress = {
|
||||
current: number;
|
||||
total: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type WizardPrompter = {
|
||||
intro: (title: string) => Promise<void>;
|
||||
outro: (message: string) => Promise<void>;
|
||||
@ -42,6 +51,11 @@ export type WizardPrompter = {
|
||||
text: (params: WizardTextParams) => Promise<string>;
|
||||
confirm: (params: WizardConfirmParams) => Promise<boolean>;
|
||||
progress: (label: string) => WizardProgress;
|
||||
/**
|
||||
* Set the current step progress (displayed before major prompts).
|
||||
* Pass null to clear the step indicator.
|
||||
*/
|
||||
setStepProgress: (progress: StepProgress | null) => void;
|
||||
};
|
||||
|
||||
export class WizardCancelledError extends Error {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js";
|
||||
import {
|
||||
WizardCancelledError,
|
||||
type StepProgress,
|
||||
type WizardProgress,
|
||||
type WizardPrompter,
|
||||
} from "./prompts.js";
|
||||
|
||||
export type WizardStepOption = {
|
||||
value: unknown;
|
||||
@ -153,6 +158,11 @@ class WizardSessionPrompter implements WizardPrompter {
|
||||
};
|
||||
}
|
||||
|
||||
setStepProgress(_progress: StepProgress | null): void {
|
||||
// Session-based wizard doesn't display step progress inline
|
||||
// (the client UI can track steps separately if needed)
|
||||
}
|
||||
|
||||
private async prompt(step: Omit<WizardStep, "id">): Promise<unknown> {
|
||||
return await this.session.awaitAnswer({
|
||||
...step,
|
||||
|
||||
@ -281,6 +281,74 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Voice input button */
|
||||
.chat-voice-btn {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-voice-btn:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-voice-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-voice-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Recording state */
|
||||
.chat-voice-btn--recording {
|
||||
background: var(--danger-subtle);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
animation: voice-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-voice-btn--recording:hover:not(:disabled) {
|
||||
background: var(--danger-subtle);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@keyframes voice-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Voice error message */
|
||||
.chat-voice-error {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
|
||||
/* Chat controls - moved to content-header area, left aligned */
|
||||
.chat-controls {
|
||||
display: flex;
|
||||
|
||||
@ -274,6 +274,143 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Skills Quick Toggle - Topbar dropdown
|
||||
=========================================== */
|
||||
|
||||
.skills-quick-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--secondary);
|
||||
color: var(--text);
|
||||
transition: border-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.skills-quick-toggle__button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.skills-quick-toggle__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__icon svg {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.skills-quick-toggle__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.skills-quick-toggle__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
max-height: 400px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__close:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.skills-quick-toggle__list {
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.skills-quick-toggle__item:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.skills-quick-toggle__item input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__name {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Status Dot - With glow for emphasis
|
||||
=========================================== */
|
||||
|
||||
@ -1445,3 +1445,179 @@
|
||||
min-width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Config Presets - Quick config profiles
|
||||
=========================================== */
|
||||
|
||||
.config-presets {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.config-presets__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.config-presets__trigger svg {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.config-presets__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.config-presets__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-presets__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-presets__close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.config-presets__close:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-presets__list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.config-presets__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.config-presets__item:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.config-presets__emoji {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.config-presets__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-presets__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.config-presets__desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.config-presets__footer {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Config View Levels - Complexity filter
|
||||
=========================================== */
|
||||
|
||||
.config-view-level {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.config-view-level__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-view-level__toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.config-view-level__btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
transition:
|
||||
background var(--duration-fast) var(--ease-out),
|
||||
color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.config-view-level__btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-view-level__btn.active {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@ -372,3 +372,257 @@
|
||||
height: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Mobile UX Polish
|
||||
- Safe area insets for iPhone notch/home indicator
|
||||
- 44px minimum touch targets (Apple HIG)
|
||||
- 16px fonts to prevent iOS zoom on focus
|
||||
- Bottom sheet pattern for dropdowns
|
||||
=========================================== */
|
||||
|
||||
/* Safe area insets for iPhone notch and home indicator */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
@media (max-width: 600px) {
|
||||
.shell {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding-top: calc(10px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
/* Bottom compose area needs safe area */
|
||||
.chat-compose {
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* Nav needs horizontal safe areas */
|
||||
.nav {
|
||||
padding-left: calc(10px + env(safe-area-inset-left));
|
||||
padding-right: calc(10px + env(safe-area-inset-right));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 44px minimum touch targets for mobile (Apple HIG) */
|
||||
@media (max-width: 600px) {
|
||||
/* Buttons - minimum 44px height */
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
/* Icon buttons */
|
||||
.btn--icon,
|
||||
.topbar-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Nav items - 44px touch target */
|
||||
.nav-item {
|
||||
min-height: 44px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Chat compose buttons */
|
||||
.chat-compose__actions .btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
/* Voice button */
|
||||
.chat-voice-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* Skills quick-toggle */
|
||||
.skills-quick-toggle__btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__item label {
|
||||
min-height: 44px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
/* Config presets */
|
||||
.config-preset-btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.config-preset {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* View level toggle */
|
||||
.config-view-level__option {
|
||||
min-height: 44px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Select dropdowns */
|
||||
.field select {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Checkboxes and radios with label */
|
||||
.field input[type="checkbox"],
|
||||
.field input[type="radio"] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* List items */
|
||||
.list-item {
|
||||
min-height: 44px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 16px font for inputs to prevent iOS zoom on focus */
|
||||
@media (max-width: 600px) {
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Chat compose textarea */
|
||||
.chat-compose__field textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Search inputs */
|
||||
input[type="search"],
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="tel"],
|
||||
input[type="url"] {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom sheet pattern for dropdowns on mobile */
|
||||
@media (max-width: 600px) {
|
||||
/* Skills quick-toggle as bottom sheet */
|
||||
.skills-quick-toggle {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
max-height: 60vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.25);
|
||||
animation: slide-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
.skills-quick-toggle__panel::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
margin: 8px auto 4px;
|
||||
}
|
||||
|
||||
/* Config preset panel as bottom sheet */
|
||||
.config-preset-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
max-height: 70vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.25);
|
||||
animation: slide-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
.config-preset-panel::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
margin: 8px auto 4px;
|
||||
}
|
||||
|
||||
/* Backdrop for bottom sheets */
|
||||
.skills-quick-toggle__panel,
|
||||
.config-preset-panel {
|
||||
backdrop-filter: blur(8px);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Additional mobile polish */
|
||||
@media (max-width: 600px) {
|
||||
/* Larger checkbox/toggle spacing */
|
||||
.toggle-field {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Better card action spacing */
|
||||
.card-actions {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Compact callout on mobile */
|
||||
.callout {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Better form field labels */
|
||||
.field > span {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Scroll momentum */
|
||||
.chat-thread,
|
||||
.log-stream,
|
||||
.content {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { html } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
import type { AppViewState } from "./app-view-state";
|
||||
@ -6,9 +6,10 @@ import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
|
||||
import { icons } from "./icons";
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import { syncUrlWithSessionKey } from "./app-settings";
|
||||
import type { SessionsListResult } from "./types";
|
||||
import type { SessionsListResult, SkillStatusEntry } from "./types";
|
||||
import type { ThemeMode } from "./theme";
|
||||
import type { ThemeTransitionContext } from "./theme-transition";
|
||||
import { updateSkillEnabled } from "./controllers/skills";
|
||||
|
||||
export function renderTab(state: AppViewState, tab: Tab) {
|
||||
const href = pathForTab(tab, state.basePath);
|
||||
@ -240,3 +241,79 @@ function renderMonitorIcon() {
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderSkillsQuickToggle(state: AppViewState) {
|
||||
const skills = state.skillsReport?.skills ?? [];
|
||||
const eligibleSkills = skills.filter((s) => s.eligible);
|
||||
const enabledCount = eligibleSkills.filter((s) => !s.disabled).length;
|
||||
const isOpen = state.settings.skillsPanelOpen ?? false;
|
||||
|
||||
const togglePanel = () => {
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
skillsPanelOpen: !isOpen,
|
||||
});
|
||||
};
|
||||
|
||||
const closePanel = () => {
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
skillsPanelOpen: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggle = (skill: SkillStatusEntry) => {
|
||||
void updateSkillEnabled(state, skill.skillKey, skill.disabled);
|
||||
};
|
||||
|
||||
const sparklesIcon = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l1.912 5.813a2 2 0 001.275 1.275L21 12l-5.813 1.912a2 2 0 00-1.275 1.275L12 21l-1.912-5.813a2 2 0 00-1.275-1.275L3 12l5.813-1.912a2 2 0 001.275-1.275L12 3z"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="skills-quick-toggle">
|
||||
<button
|
||||
class="skills-quick-toggle__button pill"
|
||||
@click=${togglePanel}
|
||||
aria-expanded=${isOpen}
|
||||
aria-haspopup="true"
|
||||
title="Quick-toggle skills"
|
||||
>
|
||||
<span class="skills-quick-toggle__icon">${sparklesIcon}</span>
|
||||
<span>Skills</span>
|
||||
<span class="skills-quick-toggle__count">${enabledCount}/${eligibleSkills.length}</span>
|
||||
</button>
|
||||
${isOpen
|
||||
? html`
|
||||
<div class="skills-quick-toggle__backdrop" @click=${closePanel}></div>
|
||||
<div class="skills-quick-toggle__panel">
|
||||
<div class="skills-quick-toggle__header">
|
||||
<span>Toggle Skills</span>
|
||||
<button class="skills-quick-toggle__close" @click=${closePanel}>×</button>
|
||||
</div>
|
||||
<div class="skills-quick-toggle__list">
|
||||
${eligibleSkills.length === 0
|
||||
? html`<div class="skills-quick-toggle__empty">No eligible skills</div>`
|
||||
: eligibleSkills.map(
|
||||
(skill) => html`
|
||||
<label class="skills-quick-toggle__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${!skill.disabled}
|
||||
@change=${() => handleToggle(skill)}
|
||||
/>
|
||||
<span class="skills-quick-toggle__name">
|
||||
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
|
||||
</span>
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ import {
|
||||
rotateDeviceToken,
|
||||
} from "./controllers/devices";
|
||||
import { renderSkills } from "./views/skills";
|
||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
|
||||
import { renderChatControls, renderSkillsQuickToggle, renderTab, renderThemeToggle } from "./app-render.helpers";
|
||||
import { loadChannels } from "./controllers/channels";
|
||||
import { loadPresence } from "./controllers/presence";
|
||||
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
|
||||
@ -66,12 +66,14 @@ import { loadNodes } from "./controllers/nodes";
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import {
|
||||
applyConfig,
|
||||
applyConfigPreset,
|
||||
loadConfig,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
updateConfigFormValue,
|
||||
removeConfigFormValue,
|
||||
} from "./controllers/config";
|
||||
import { getPreset } from "./config-presets";
|
||||
import {
|
||||
loadExecApprovals,
|
||||
removeExecApprovalsFormValue,
|
||||
@ -143,6 +145,7 @@ export function renderApp(state: AppViewState) {
|
||||
<span>Health</span>
|
||||
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
||||
</div>
|
||||
${renderSkillsQuickToggle(state)}
|
||||
${renderThemeToggle(state)}
|
||||
</div>
|
||||
</header>
|
||||
@ -480,6 +483,11 @@ export function renderApp(state: AppViewState) {
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
// Voice input props
|
||||
voiceRecording: state.voiceRecording,
|
||||
voiceInterim: state.voiceInterim,
|
||||
voiceError: state.voiceError,
|
||||
onVoiceToggle: () => state.handleVoiceToggle(),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
@ -530,10 +538,22 @@ export function renderApp(state: AppViewState) {
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
presetPanelOpen: state.configPresetPanelOpen,
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
onApplyPreset: (presetId) => {
|
||||
const preset = getPreset(presetId);
|
||||
if (preset) {
|
||||
applyConfigPreset(state, preset.values);
|
||||
}
|
||||
},
|
||||
onTogglePresetPanel: () => {
|
||||
state.configPresetPanelOpen = !state.configPresetPanelOpen;
|
||||
},
|
||||
viewLevel: state.configViewLevel,
|
||||
onViewLevelChange: (level) => (state.configViewLevel = level),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
|
||||
@ -63,6 +63,10 @@ import {
|
||||
handleSendChat as handleSendChatInternal,
|
||||
removeQueuedMessage as removeQueuedMessageInternal,
|
||||
} from "./app-chat";
|
||||
import {
|
||||
startVoiceRecognition,
|
||||
stopVoiceRecognition,
|
||||
} from "./voice-input";
|
||||
import {
|
||||
handleChannelConfigReload as handleChannelConfigReloadInternal,
|
||||
handleChannelConfigSave as handleChannelConfigSaveInternal,
|
||||
@ -130,6 +134,11 @@ export class MoltbotApp extends LitElement {
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@state() chatAttachments: ChatAttachment[] = [];
|
||||
// Voice input state
|
||||
@state() voiceRecording = false;
|
||||
@state() voiceInterim = "";
|
||||
@state() voiceError: string | null = null;
|
||||
voiceStopFn: (() => void) | null = null;
|
||||
// Sidebar state for tool output viewing
|
||||
@state() sidebarOpen = false;
|
||||
@state() sidebarContent: string | null = null;
|
||||
@ -174,6 +183,8 @@ export class MoltbotApp extends LitElement {
|
||||
@state() configSearchQuery = "";
|
||||
@state() configActiveSection: string | null = null;
|
||||
@state() configActiveSubsection: string | null = null;
|
||||
@state() configPresetPanelOpen = false;
|
||||
@state() configViewLevel: "basic" | "standard" | "advanced" = "standard";
|
||||
|
||||
@state() channelsLoading = false;
|
||||
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
||||
@ -386,6 +397,48 @@ export class MoltbotApp extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
handleVoiceToggle() {
|
||||
if (this.voiceRecording) {
|
||||
// Stop recording
|
||||
stopVoiceRecognition();
|
||||
this.voiceRecording = false;
|
||||
this.voiceInterim = "";
|
||||
this.voiceStopFn = null;
|
||||
} else {
|
||||
// Start recording
|
||||
this.voiceError = null;
|
||||
this.voiceInterim = "";
|
||||
const stopFn = startVoiceRecognition({
|
||||
onTranscript: (text, isFinal) => {
|
||||
if (isFinal) {
|
||||
// Append final transcript to draft
|
||||
const separator = this.chatMessage && !this.chatMessage.endsWith(" ") ? " " : "";
|
||||
this.chatMessage = this.chatMessage + separator + text;
|
||||
this.voiceInterim = "";
|
||||
} else {
|
||||
// Show interim result
|
||||
this.voiceInterim = text;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
this.voiceError = error;
|
||||
this.voiceRecording = false;
|
||||
this.voiceInterim = "";
|
||||
this.voiceStopFn = null;
|
||||
},
|
||||
onStart: () => {
|
||||
this.voiceRecording = true;
|
||||
},
|
||||
onEnd: () => {
|
||||
this.voiceRecording = false;
|
||||
this.voiceInterim = "";
|
||||
this.voiceStopFn = null;
|
||||
},
|
||||
});
|
||||
this.voiceStopFn = stopFn;
|
||||
}
|
||||
}
|
||||
|
||||
async handleWhatsAppStart(force: boolean) {
|
||||
await handleWhatsAppStartInternal(this, force);
|
||||
}
|
||||
|
||||
163
ui/src/ui/config-presets.ts
Normal file
163
ui/src/ui/config-presets.ts
Normal file
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Config presets for quick configuration profiles.
|
||||
* Each preset applies a set of config values optimized for specific use cases.
|
||||
*/
|
||||
|
||||
export type ConfigPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
/** Partial config object to merge into the current config */
|
||||
values: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const CONFIG_PRESETS: ConfigPreset[] = [
|
||||
{
|
||||
id: "fast-chat",
|
||||
name: "Fast Chat",
|
||||
emoji: "\u26a1",
|
||||
description: "Optimized for quick, responsive conversations with minimal latency.",
|
||||
values: {
|
||||
agents: {
|
||||
defaults: {
|
||||
thinkingDefault: "minimal",
|
||||
verboseDefault: "off",
|
||||
timeoutSeconds: 60,
|
||||
blockStreamingDefault: "on",
|
||||
typingMode: "instant",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "coding-agent",
|
||||
name: "Coding Agent",
|
||||
emoji: "\ud83d\udcbb",
|
||||
description: "Enhanced reasoning and tools for software development tasks.",
|
||||
values: {
|
||||
agents: {
|
||||
defaults: {
|
||||
thinkingDefault: "high",
|
||||
verboseDefault: "on",
|
||||
elevatedDefault: "on",
|
||||
timeoutSeconds: 300,
|
||||
contextTokens: 180000,
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
bash: { enabled: true },
|
||||
edit: { enabled: true },
|
||||
read: { enabled: true },
|
||||
write: { enabled: true },
|
||||
glob: { enabled: true },
|
||||
grep: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "voice-assistant",
|
||||
name: "Voice Assistant",
|
||||
emoji: "\ud83c\udf99\ufe0f",
|
||||
description: "Optimized for voice input and natural conversation flow.",
|
||||
values: {
|
||||
agents: {
|
||||
defaults: {
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
blockStreamingDefault: "on",
|
||||
blockStreamingBreak: "text_end",
|
||||
typingMode: "never",
|
||||
humanDelay: {
|
||||
enabled: true,
|
||||
minMs: 500,
|
||||
maxMs: 1500,
|
||||
},
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
transcription: {
|
||||
enabled: true,
|
||||
provider: "whisper",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "privacy-first",
|
||||
name: "Privacy First",
|
||||
emoji: "\ud83d\udd12",
|
||||
description: "Maximum privacy with minimal data retention and logging.",
|
||||
values: {
|
||||
logging: {
|
||||
level: "error",
|
||||
redactSensitive: "tools",
|
||||
},
|
||||
diagnostics: {
|
||||
enabled: false,
|
||||
otel: {
|
||||
enabled: false,
|
||||
},
|
||||
cacheTrace: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Deep merge two objects, with source values overwriting target values.
|
||||
*/
|
||||
function deepMerge(
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
if (
|
||||
sourceValue !== null &&
|
||||
typeof sourceValue === "object" &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
targetValue !== null &&
|
||||
typeof targetValue === "object" &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a preset to a config object, merging the preset values.
|
||||
*/
|
||||
export function applyPreset(
|
||||
config: Record<string, unknown>,
|
||||
presetId: string,
|
||||
): Record<string, unknown> {
|
||||
const preset = CONFIG_PRESETS.find((p) => p.id === presetId);
|
||||
if (!preset) return config;
|
||||
return deepMerge(config, preset.values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preset by ID.
|
||||
*/
|
||||
export function getPreset(presetId: string): ConfigPreset | undefined {
|
||||
return CONFIG_PRESETS.find((p) => p.id === presetId);
|
||||
}
|
||||
176
ui/src/ui/config-view-levels.ts
Normal file
176
ui/src/ui/config-view-levels.ts
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Config view levels for filtering fields by complexity.
|
||||
* - Basic: Essential settings for getting started (~20 fields)
|
||||
* - Standard: Common settings for most users (~80 fields)
|
||||
* - Advanced: All settings including expert-only options (~260 fields)
|
||||
*/
|
||||
|
||||
export type ConfigViewLevel = "basic" | "standard" | "advanced";
|
||||
|
||||
export const VIEW_LEVELS: Array<{ value: ConfigViewLevel; label: string; description: string }> = [
|
||||
{ value: "basic", label: "Basic", description: "Essential settings only" },
|
||||
{ value: "standard", label: "Standard", description: "Common settings" },
|
||||
{ value: "advanced", label: "Advanced", description: "All settings" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Basic-level fields: Essential settings for getting started.
|
||||
* These are the minimum fields a new user needs.
|
||||
*/
|
||||
export const BASIC_FIELDS = new Set([
|
||||
// Models
|
||||
"models",
|
||||
"models.anthropic",
|
||||
"models.anthropic.apiKey",
|
||||
"models.openai",
|
||||
"models.openai.apiKey",
|
||||
|
||||
// Agent basics
|
||||
"agents",
|
||||
"agents.defaults",
|
||||
"agents.defaults.model",
|
||||
"agents.defaults.model.primary",
|
||||
"agents.defaults.thinkingDefault",
|
||||
"agents.defaults.workspace",
|
||||
|
||||
// Channels (top-level)
|
||||
"channels",
|
||||
"channels.telegram",
|
||||
"channels.discord",
|
||||
"channels.slack",
|
||||
"channels.whatsapp",
|
||||
|
||||
// Gateway basics
|
||||
"gateway",
|
||||
"gateway.port",
|
||||
"gateway.mode",
|
||||
|
||||
// Update channel
|
||||
"update",
|
||||
"update.channel",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Standard-level fields: Common settings most users will adjust.
|
||||
* Includes basic fields plus commonly-used options.
|
||||
*/
|
||||
export const STANDARD_SECTIONS = new Set([
|
||||
// All basic fields are included
|
||||
"models",
|
||||
"agents",
|
||||
"channels",
|
||||
"gateway",
|
||||
"update",
|
||||
|
||||
// Additional standard sections
|
||||
"tools",
|
||||
"skills",
|
||||
"messages",
|
||||
"commands",
|
||||
"session",
|
||||
"logging",
|
||||
"audio",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Paths that are explicitly advanced-only even within standard sections.
|
||||
* These are hidden unless view level is "advanced".
|
||||
*/
|
||||
export const ADVANCED_ONLY_PATHS = new Set([
|
||||
// Diagnostics and debugging
|
||||
"diagnostics",
|
||||
"diagnostics.otel",
|
||||
"diagnostics.cacheTrace",
|
||||
|
||||
// Expert agent settings
|
||||
"agents.defaults.contextPruning",
|
||||
"agents.defaults.compaction",
|
||||
"agents.defaults.sandbox",
|
||||
"agents.defaults.subagents",
|
||||
"agents.defaults.heartbeat",
|
||||
"agents.defaults.memorySearch",
|
||||
|
||||
// Expert gateway settings
|
||||
"gateway.security",
|
||||
"gateway.rateLimit",
|
||||
"gateway.cors",
|
||||
"gateway.tls",
|
||||
|
||||
// Browser advanced
|
||||
"browser.cdpUrl",
|
||||
"browser.remoteCdpTimeoutMs",
|
||||
"browser.remoteCdpHandshakeTimeoutMs",
|
||||
"browser.snapshotDefaults",
|
||||
"browser.profiles",
|
||||
|
||||
// Internal
|
||||
"meta",
|
||||
"wizard",
|
||||
"env.shellEnv",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a path should be shown at the given view level.
|
||||
* @param path - Dot-separated config path (e.g., "agents.defaults.model")
|
||||
* @param level - Current view level
|
||||
* @param isAdvancedHint - Whether the field has advanced: true in uiHints
|
||||
*/
|
||||
export function isPathVisibleAtLevel(
|
||||
path: string,
|
||||
level: ConfigViewLevel,
|
||||
isAdvancedHint: boolean = false,
|
||||
): boolean {
|
||||
// Advanced level shows everything
|
||||
if (level === "advanced") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if this path is explicitly advanced-only
|
||||
if (ADVANCED_ONLY_PATHS.has(path) || isAdvancedHint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For basic level, only show basic fields
|
||||
if (level === "basic") {
|
||||
// Check exact match
|
||||
if (BASIC_FIELDS.has(path)) return true;
|
||||
|
||||
// Check if any parent or prefix matches
|
||||
const parts = path.split(".");
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const prefix = parts.slice(0, i).join(".");
|
||||
if (BASIC_FIELDS.has(prefix)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Standard level: show if section is in standard sections
|
||||
const topLevel = path.split(".")[0];
|
||||
return STANDARD_SECTIONS.has(topLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a section (top-level key) should be shown at the given view level.
|
||||
*/
|
||||
export function isSectionVisibleAtLevel(
|
||||
sectionKey: string,
|
||||
level: ConfigViewLevel,
|
||||
): boolean {
|
||||
if (level === "advanced") return true;
|
||||
|
||||
if (level === "basic") {
|
||||
return BASIC_FIELDS.has(sectionKey);
|
||||
}
|
||||
|
||||
// Standard
|
||||
return STANDARD_SECTIONS.has(sectionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description for the current view level.
|
||||
*/
|
||||
export function getViewLevelDescription(level: ConfigViewLevel): string {
|
||||
const info = VIEW_LEVELS.find((l) => l.value === level);
|
||||
return info?.description ?? "";
|
||||
}
|
||||
@ -200,3 +200,51 @@ export function removeConfigFormValue(
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a preset configuration by merging its values into the current config.
|
||||
*/
|
||||
export function applyConfigPreset(
|
||||
state: ConfigState,
|
||||
presetValues: Record<string, unknown>,
|
||||
) {
|
||||
const base = cloneConfigObject(
|
||||
state.configForm ?? state.configSnapshot?.config ?? {},
|
||||
);
|
||||
const merged = deepMergeConfig(base, presetValues);
|
||||
state.configForm = merged;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(merged);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge preset values into a config object.
|
||||
*/
|
||||
function deepMergeConfig(
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
if (
|
||||
sourceValue !== null &&
|
||||
typeof sourceValue === "object" &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
targetValue !== null &&
|
||||
typeof targetValue === "object" &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMergeConfig(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -39,6 +39,8 @@ export const icons = {
|
||||
plug: html`<svg viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg>`,
|
||||
circle: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`,
|
||||
puzzle: html`<svg viewBox="0 0 24 24"><path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.076.874.54 1.02 1.02a2.5 2.5 0 1 0 3.237-3.237c-.48-.146-.944-.505-1.02-1.02a.98.98 0 0 1 .303-.917l1.526-1.526A2.402 2.402 0 0 1 11.998 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.236 3.236c-.464.18-.894.527-.967 1.02Z"/></svg>`,
|
||||
mic: html`<svg viewBox="0 0 24 24"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>`,
|
||||
micOff: html`<svg viewBox="0 0 24 24"><line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/></svg>`,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@ -13,6 +13,7 @@ export type UiSettings = {
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
skillsPanelOpen: boolean; // Skills quick-toggle panel state
|
||||
};
|
||||
|
||||
export function loadSettings(): UiSettings {
|
||||
@ -32,6 +33,7 @@ export function loadSettings(): UiSettings {
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
skillsPanelOpen: false,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -84,6 +86,10 @@ export function loadSettings(): UiSettings {
|
||||
parsed.navGroupsCollapsed !== null
|
||||
? parsed.navGroupsCollapsed
|
||||
: defaults.navGroupsCollapsed,
|
||||
skillsPanelOpen:
|
||||
typeof parsed.skillsPanelOpen === "boolean"
|
||||
? parsed.skillsPanelOpen
|
||||
: defaults.skillsPanelOpen,
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
renderStreamingGroup,
|
||||
} from "../chat/grouped-render";
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||
import { isVoiceInputSupported } from "../voice-input";
|
||||
import "../components/resizable-divider";
|
||||
|
||||
export type CompactionIndicatorStatus = {
|
||||
@ -56,6 +57,11 @@ export type ChatProps = {
|
||||
// Image attachments
|
||||
attachments?: ChatAttachment[];
|
||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||
// Voice input state
|
||||
voiceRecording?: boolean;
|
||||
voiceInterim?: string;
|
||||
voiceError?: string | null;
|
||||
onVoiceToggle?: () => void;
|
||||
// Event handlers
|
||||
onRefresh: () => void;
|
||||
onToggleFocusMode: () => void;
|
||||
@ -329,12 +335,17 @@ export function renderChat(props: ChatProps) {
|
||||
|
||||
<div class="chat-compose">
|
||||
${renderAttachmentPreview(props)}
|
||||
${props.voiceError
|
||||
? html`<div class="chat-voice-error">${props.voiceError}</div>`
|
||||
: nothing}
|
||||
<div class="chat-compose__row">
|
||||
<label class="field chat-compose__field">
|
||||
<span>Message</span>
|
||||
<textarea
|
||||
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
|
||||
.value=${props.draft}
|
||||
.value=${props.voiceRecording && props.voiceInterim
|
||||
? props.draft + props.voiceInterim
|
||||
: props.draft}
|
||||
?disabled=${!props.connected}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter") return;
|
||||
@ -350,9 +361,31 @@ export function renderChat(props: ChatProps) {
|
||||
props.onDraftChange(target.value);
|
||||
}}
|
||||
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
|
||||
placeholder=${composePlaceholder}
|
||||
placeholder=${props.voiceRecording
|
||||
? "Listening..."
|
||||
: composePlaceholder}
|
||||
></textarea>
|
||||
</label>
|
||||
${isVoiceInputSupported() && props.onVoiceToggle
|
||||
? html`
|
||||
<button
|
||||
class="btn chat-voice-btn ${props.voiceRecording
|
||||
? "chat-voice-btn--recording"
|
||||
: ""}"
|
||||
type="button"
|
||||
?disabled=${!props.connected}
|
||||
@click=${props.onVoiceToggle}
|
||||
aria-label=${props.voiceRecording
|
||||
? "Stop recording"
|
||||
: "Start voice input"}
|
||||
title=${props.voiceRecording
|
||||
? "Stop recording"
|
||||
: "Voice input"}
|
||||
>
|
||||
${props.voiceRecording ? icons.micOff : icons.mic}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<div class="chat-compose__actions">
|
||||
<button
|
||||
class="btn"
|
||||
|
||||
@ -7,6 +7,12 @@ import {
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared";
|
||||
import { CONFIG_PRESETS, type ConfigPreset } from "../config-presets";
|
||||
import {
|
||||
VIEW_LEVELS,
|
||||
isSectionVisibleAtLevel,
|
||||
type ConfigViewLevel,
|
||||
} from "../config-view-levels";
|
||||
|
||||
export type ConfigProps = {
|
||||
raw: string;
|
||||
@ -27,6 +33,8 @@ export type ConfigProps = {
|
||||
searchQuery: string;
|
||||
activeSection: string | null;
|
||||
activeSubsection: string | null;
|
||||
presetPanelOpen: boolean;
|
||||
viewLevel: "basic" | "standard" | "advanced";
|
||||
onRawChange: (next: string) => void;
|
||||
onFormModeChange: (mode: "form" | "raw") => void;
|
||||
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
@ -37,6 +45,9 @@ export type ConfigProps = {
|
||||
onSave: () => void;
|
||||
onApply: () => void;
|
||||
onUpdate: () => void;
|
||||
onApplyPreset: (presetId: string) => void;
|
||||
onTogglePresetPanel: () => void;
|
||||
onViewLevelChange: (level: "basic" | "standard" | "advanced") => void;
|
||||
};
|
||||
|
||||
// SVG Icons for sidebar (Lucide-style)
|
||||
@ -181,6 +192,57 @@ function truncateValue(value: unknown, maxLen = 40): string {
|
||||
return str.slice(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
function renderPresetSelector(props: ConfigProps) {
|
||||
const presetIcon = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l1.912 5.813a2 2 0 001.275 1.275L21 12l-5.813 1.912a2 2 0 00-1.275 1.275L12 21l-1.912-5.813a2 2 0 00-1.275-1.275L3 12l5.813-1.912a2 2 0 001.275-1.275L12 3z"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="config-presets">
|
||||
<button
|
||||
class="config-presets__trigger btn btn--sm"
|
||||
@click=${props.onTogglePresetPanel}
|
||||
?disabled=${props.formMode !== "form"}
|
||||
title=${props.formMode !== "form" ? "Presets only available in Form mode" : "Apply a preset configuration"}
|
||||
>
|
||||
${presetIcon}
|
||||
<span>Presets</span>
|
||||
</button>
|
||||
${props.presetPanelOpen ? html`
|
||||
<div class="config-presets__backdrop" @click=${props.onTogglePresetPanel}></div>
|
||||
<div class="config-presets__panel">
|
||||
<div class="config-presets__header">
|
||||
<span>Quick Config Presets</span>
|
||||
<button class="config-presets__close" @click=${props.onTogglePresetPanel}>×</button>
|
||||
</div>
|
||||
<div class="config-presets__list">
|
||||
${CONFIG_PRESETS.map((preset) => html`
|
||||
<button
|
||||
class="config-presets__item"
|
||||
@click=${() => {
|
||||
props.onApplyPreset(preset.id);
|
||||
props.onTogglePresetPanel();
|
||||
}}
|
||||
>
|
||||
<span class="config-presets__emoji">${preset.emoji}</span>
|
||||
<div class="config-presets__info">
|
||||
<div class="config-presets__name">${preset.name}</div>
|
||||
<div class="config-presets__desc">${preset.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="config-presets__footer">
|
||||
Presets merge with your current configuration
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderConfig(props: ConfigProps) {
|
||||
const validity =
|
||||
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
|
||||
@ -201,6 +263,11 @@ export function renderConfig(props: ConfigProps) {
|
||||
|
||||
const allSections = [...availableSections, ...extraSections];
|
||||
|
||||
// Filter sections based on view level
|
||||
const visibleSections = props.formMode === "form"
|
||||
? allSections.filter((s) => isSectionVisibleAtLevel(s.key, props.viewLevel))
|
||||
: allSections;
|
||||
|
||||
const activeSectionSchema =
|
||||
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
|
||||
? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined)
|
||||
@ -289,7 +356,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
<span class="config-nav__icon">${sidebarIcons.all}</span>
|
||||
<span class="config-nav__label">All Settings</span>
|
||||
</button>
|
||||
${allSections.map(section => html`
|
||||
${visibleSections.map(section => html`
|
||||
<button
|
||||
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
|
||||
@click=${() => props.onSectionChange(section.key)}
|
||||
@ -300,8 +367,24 @@ export function renderConfig(props: ConfigProps) {
|
||||
`)}
|
||||
</nav>
|
||||
|
||||
<!-- Mode toggle at bottom -->
|
||||
<!-- View level and mode toggle at bottom -->
|
||||
<div class="config-sidebar__footer">
|
||||
${props.formMode === "form" ? html`
|
||||
<div class="config-view-level">
|
||||
<label class="config-view-level__label">View Level</label>
|
||||
<div class="config-view-level__toggle">
|
||||
${VIEW_LEVELS.map((level) => html`
|
||||
<button
|
||||
class="config-view-level__btn ${props.viewLevel === level.value ? "active" : ""}"
|
||||
@click=${() => props.onViewLevelChange(level.value)}
|
||||
title=${level.description}
|
||||
>
|
||||
${level.label}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="config-mode-toggle">
|
||||
<button
|
||||
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
|
||||
@ -325,6 +408,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
<!-- Action bar -->
|
||||
<div class="config-actions">
|
||||
<div class="config-actions__left">
|
||||
${renderPresetSelector(props)}
|
||||
${hasChanges ? html`
|
||||
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
|
||||
` : html`
|
||||
|
||||
200
ui/src/ui/voice-input.ts
Normal file
200
ui/src/ui/voice-input.ts
Normal file
@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Voice input utility using Web Speech API.
|
||||
* Provides feature detection, speech recognition, and transcription.
|
||||
*/
|
||||
|
||||
// Web Speech API types (not available in TypeScript by default)
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number;
|
||||
item(index: number): SpeechRecognitionAlternative;
|
||||
[index: number]: SpeechRecognitionAlternative;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionAlternative {
|
||||
readonly transcript: string;
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
item(index: number): SpeechRecognitionResult;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
readonly results: SpeechRecognitionResultList;
|
||||
readonly resultIndex: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
readonly error: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onstart: (() => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionConstructor {
|
||||
new (): SpeechRecognition;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition?: SpeechRecognitionConstructor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Web Speech API is supported in the browser.
|
||||
*/
|
||||
export function isVoiceInputSupported(): boolean {
|
||||
return Boolean(
|
||||
typeof window !== "undefined" &&
|
||||
(window.SpeechRecognition || window.webkitSpeechRecognition),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SpeechRecognition constructor if available.
|
||||
*/
|
||||
function getSpeechRecognition(): SpeechRecognitionConstructor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.SpeechRecognition || window.webkitSpeechRecognition || null;
|
||||
}
|
||||
|
||||
export type VoiceInputState = {
|
||||
isRecording: boolean;
|
||||
transcript: string;
|
||||
interimTranscript: string;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type VoiceInputCallbacks = {
|
||||
onTranscript: (text: string, isFinal: boolean) => void;
|
||||
onError: (error: string) => void;
|
||||
onStart: () => void;
|
||||
onEnd: () => void;
|
||||
};
|
||||
|
||||
let activeRecognition: SpeechRecognition | null = null;
|
||||
|
||||
/**
|
||||
* Start voice recognition and call back with transcriptions.
|
||||
* Returns a stop function, or null if not supported.
|
||||
*/
|
||||
export function startVoiceRecognition(
|
||||
callbacks: VoiceInputCallbacks,
|
||||
): (() => void) | null {
|
||||
const SpeechRecognitionClass = getSpeechRecognition();
|
||||
if (!SpeechRecognitionClass) {
|
||||
callbacks.onError("Voice input not supported in this browser");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stop any existing recognition
|
||||
if (activeRecognition) {
|
||||
activeRecognition.abort();
|
||||
activeRecognition = null;
|
||||
}
|
||||
|
||||
const recognition = new SpeechRecognitionClass();
|
||||
activeRecognition = recognition;
|
||||
|
||||
// Configure recognition
|
||||
recognition.continuous = false; // Single utterance mode for mobile
|
||||
recognition.interimResults = true; // Show partial results
|
||||
recognition.lang = navigator.language || "en-US";
|
||||
|
||||
recognition.onstart = () => {
|
||||
callbacks.onStart();
|
||||
};
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let finalTranscript = "";
|
||||
let interimTranscript = "";
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
if (result[0]) {
|
||||
if (result.isFinal) {
|
||||
finalTranscript += result[0].transcript;
|
||||
} else {
|
||||
interimTranscript += result[0].transcript;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
callbacks.onTranscript(finalTranscript, true);
|
||||
} else if (interimTranscript) {
|
||||
callbacks.onTranscript(interimTranscript, false);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
let errorMessage = "Voice recognition error";
|
||||
switch (event.error) {
|
||||
case "no-speech":
|
||||
errorMessage = "No speech detected. Try again.";
|
||||
break;
|
||||
case "audio-capture":
|
||||
errorMessage = "Microphone not available";
|
||||
break;
|
||||
case "not-allowed":
|
||||
errorMessage = "Microphone access denied";
|
||||
break;
|
||||
case "network":
|
||||
errorMessage = "Network error during recognition";
|
||||
break;
|
||||
case "aborted":
|
||||
// User cancelled, not an error
|
||||
return;
|
||||
default:
|
||||
errorMessage = `Recognition error: ${event.error}`;
|
||||
}
|
||||
callbacks.onError(errorMessage);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
if (activeRecognition === recognition) {
|
||||
activeRecognition = null;
|
||||
}
|
||||
callbacks.onEnd();
|
||||
};
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
} catch {
|
||||
callbacks.onError("Failed to start voice recognition");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return stop function
|
||||
return () => {
|
||||
if (activeRecognition === recognition) {
|
||||
recognition.stop();
|
||||
activeRecognition = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any active voice recognition.
|
||||
*/
|
||||
export function stopVoiceRecognition(): void {
|
||||
if (activeRecognition) {
|
||||
activeRecognition.stop();
|
||||
activeRecognition = null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user