Compare commits

..

22 Commits

Author SHA1 Message Date
Haile
a1c48e5b1d Merge branch 'main' into fix/voice-tts-format-settings 2026-06-25 17:13:12 +03:00
Haileyesus
0e6373305b fix(voice): separate client and server backends
User-selected backend URLs must remain usable without letting clients control server requests.

Call custom providers from the browser while keeping the server proxy bound to its configured host.

This restores voice controls for frontend settings without reopening the SSRF path.
2026-06-25 17:10:42 +03:00
Haileyesus
43c0cca96e fix(voice): validate config and request boundaries
Malformed stored settings could break voice requests instead of using safe defaults.

Health results could outlive auth changes. URL checks also did not guard the fetch sink.

Remove constant recorder branches so lifecycle cancellation stays clear.
2026-06-25 16:52:54 +03:00
Haileyesus
af16d8ebdc fix(voice): harden recording and backend behavior
Redirects could bypass the backend URL guard, and TTS playback waited for full buffering.

Recording could overlap or finish after teardown. Controls also ignored backend readiness.

Explicit formats and config-aware cache keys prevent stale audio after settings change.
2026-06-25 16:35:30 +03:00
Haile
c947eaaee5 feat: play sound for pending tool requests (#918) 2026-06-25 14:57:10 +02:00
Haile
b0a49120cc Merge branch 'main' into fix/voice-tts-format-settings 2026-06-25 15:56:07 +03:00
Haile
4a503b1dc8 fix(shell): prioritize user npm binaries (#913)
Interactive shells could resolve bundled or system CLIs before user-installed npm binaries.

Move existing user npm global directories to the front of PATH while preserving all other entries.

Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-24 20:15:52 +02:00
Haileyesus
8cbfac6ab1 fix(voice): expose TTS format in user settings 2026-06-24 10:05:35 +03:00
Koya Kikuchi
f6326c8082 feat(version): warn when the server was updated but not restarted (#898)
When the package is updated on disk but the long-lived server process is
not restarted, the new frontend bundle (served from disk) talks to the
old running backend. New DB-backed features then fail silently — e.g.
deleting/archiving a session appears to do nothing — because the new
schema/routes only take effect on restart.

Nothing currently detects this skew: useVersionCheck only compares the
frontend's build-time version against the latest GitHub release.

This exposes the running server's version (captured once at startup) via
/health, compares it to the frontend's build-time version in
useVersionCheck, and shows a "restart required" banner in the sidebar
(and a small indicator in the collapsed sidebar) when they differ.

- server: add `version` (RUNNING_VERSION, read once at startup) to /health
- useVersionCheck: return `restartRequired` / `runningVersion`
- SidebarFooter / SidebarCollapsed: surface a restart-required banner
- i18n: add `version.restartRequired` to all 10 sidebar locales

Verified with `tsc --noEmit` (client + server) and eslint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-22 22:49:57 +02:00
Haile
c5fe127958 feat(skills): add provider skill management (#909)
* feat(skills): add provider skill management

Users need one settings surface to discover and install skills without manually navigating provider-specific directories.

Add provider-backed global skill installation for Claude, Codex, Gemini, and Cursor, while keeping OpenCode read-only because it reuses other providers' skill locations.

Add a responsive Skills settings tab with scoped discovery, search, refresh controls, markdown and folder uploads, upload feedback, and overflow-safe layouts.

Validate bundled skill files and paths before writing them, preserve scripts and assets, and cover provider discovery and installation behavior with tests.

* fix(skills): preserve uploaded skill folders

Folder drops discarded supporting scripts and assets.

Keep relative paths and upload every file from the selected skill folder.

Use the selected folder name for installation and cover it in provider tests.

* fix(skills): restrict standalone skill uploads

Only show Markdown files when selecting standalone skills.

Normalize browser file paths so SKILL.md is not mistaken for a folder named dot.

* fix(skills): validate installs before writing

Preserve bundled files and normalize fallback names across skill installation paths.

Validate complete batches before writing and reject existing targets to avoid partial installs.

Keep project metadata and make folder selection tolerant of casing and cancelled dialogs.

* fix(skills): overwrite existing installations

Replace an existing skill directory instead of rejecting a duplicate installation.

Remove stale supporting files so the installed directory exactly matches the new upload.
2026-06-22 22:45:27 +02:00
newsbubbles
9919851be7 Merge upstream/main into feat/voice (resolve voice vs browser-use + websocket-unify) 2026-06-20 16:17:24 +01:00
chenxiccc
4712431be8 fix(chat): prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks (#903) 2026-06-19 18:40:26 +02:00
Haile
66b0766013 Merge branch 'main' into feat/voice 2026-06-15 15:27:18 +03:00
newsbubbles
95a75aac47 fix(voice): make stop() idempotent so a double tap can't throw
guard on the recorder's own state instead of react state, so a double tap or
the mic and send buttons both firing won't call stop() on an already-inactive
MediaRecorder.
2026-06-13 13:34:18 +01:00
newsbubbles
952ddb9eb7 feat(voice): send transcript with the main send button while recording
while dictating, the main send button stops recording, transcribes, and sends
in one tap, matching the codex-style flow. the mic button still stops and drops
the transcript into the input box to edit before sending. voice recording state
is lifted into the composer so both buttons share it, and the send button is
enabled (not grayed) while recording. also fix a pre-existing type error: the
quick-settings preferences map was missing voiceEnabled.
2026-06-13 13:09:14 +01:00
newsbubbles
7f8ae7023d fix(voice): harden timeout parsing, tts input check, and player abort
- fall back to the default when VOICE_TIMEOUT_MS is non-numeric or <= 0, so a
  bad override can't make the abort fire immediately
- type-check the tts `text` before calling .trim() so a non-string body returns
  400 instead of throwing
- abort the in-flight TTS fetch on stop() and on a superseding play, so tapping
  read-aloud repeatedly doesn't leave orphaned requests generating audio
2026-06-13 12:09:51 +01:00
newsbubbles
1203760ba8 docs(voice): provider-agnostic wording and jsdoc on proxy functions
drop leftover sidecar/faster-whisper references now that the backend is any
openai-compatible voice api, and add jsdoc to the voice-proxy functions so the
docstring coverage check passes.
2026-06-13 11:55:46 +01:00
newsbubbles
f285715e31 fix(voice): address review (SSRF guard, auth mapping, client timeout)
Validates the user-supplied backend URL (http/https only, blocks the link-local
metadata range) to prevent SSRF; remaps upstream 401/403 so a bad voice API key
isn't read as the app's own auth failing; adds a client-side AbortController timeout
on the read-aloud request so the button can't sit in loading if a request stalls.
2026-06-10 17:56:02 +01:00
newsbubbles
cb3ad16139 fix(voice): play read-aloud through an app-level player to stop cutoffs
Read-aloud now runs in a single module-level player outside the React tree instead
of per-message component state. Switching chats or re-rendering a message no longer
revokes the blob URL mid-play (the 'Invalid URI' cutoff). Adds content-keyed caching so
re-listening doesn't regenerate, and reuses one audio element (also unlocks iOS once).
2026-06-09 15:19:36 +01:00
newsbubbles
32a6405537 fix(voice): relax backend timeout and surface timeout errors
Bumps the proxy timeout to 5 minutes (VOICE_TIMEOUT_MS) since local TTS can
synthesize long messages at roughly real-time, and returns a clear timed-out
message (504) instead of failing silently. The read-aloud button now shows
backend errors.
2026-06-09 12:13:06 +01:00
newsbubbles
711936d279 refactor(voice): provider-agnostic backend and in-app config
Switches the voice proxy to the OpenAI audio API (/v1/audio/transcriptions and
/v1/audio/speech) so it works with OpenAI, Groq, or a local server. Adds a
Settings -> Voice tab (base URL, API key, models, voice) plus a Quick Settings
toggle, and removes the bundled Python sidecar.

Review fixes: stop mic tracks on unmount, clear the global TTS stop handler and
revoke leaked blob URLs, add fetch timeouts in the proxy, surface mic errors in
the button, trim before appending transcripts, and drop the repo-wide wav ignore.
2026-06-09 10:05:06 +01:00
newsbubbles
d05585e1f4 feat(voice): add optional speech-to-text input and read-aloud TTS
Adds a push-to-talk mic button in the composer and a read-aloud button on
assistant messages. Both are opt-in and hidden unless a voice backend is
configured via VOICE_SIDECAR_URL.

The auth-gated /api/voice proxy forwards to a configurable backend exposing
/transcribe and /tts (provider-agnostic); the frontend probes /api/voice/health
and hides the controls when disabled. Adds i18n keys and docs/voice.md.

Includes a local, no-API-key reference backend in voice-sidecar/ (faster-whisper
for STT, Kokoro-82M for TTS, both CPU-capable).
2026-06-08 00:48:24 +01:00
63 changed files with 3026 additions and 60 deletions

View File

@@ -61,6 +61,7 @@ import userRoutes from './routes/user.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
import providerRoutes from './modules/providers/provider.routes.js';
import voiceRoutes from './voice-proxy.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.js';
@@ -76,6 +77,19 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
// Version of the code that is actually running, captured once at process
// startup. This intentionally does NOT re-read package.json per request: after
// an update replaces the files on disk, package.json reflects the NEW version
// while this long-lived process still runs the OLD code. The frontend bundle is
// rebuilt on update, so a mismatch between this value and the frontend's
// build-time version means the server was updated but not restarted.
const RUNNING_VERSION = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null;
} catch {
return null;
}
})();
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
@@ -156,7 +170,8 @@ app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
installMode
installMode,
version: RUNNING_VERSION
});
});
@@ -208,6 +223,8 @@ app.use('/api/providers', authenticateToken, providerRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
app.use('/api/voice', authenticateToken, voiceRoutes);
// Serve public files (like api-docs.html)
app.use(express.static(path.join(APP_ROOT, 'public')));

View File

@@ -99,6 +99,14 @@ export class ClaudeSkillsProvider extends SkillsProvider {
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(getClaudeHomePath(), 'skills'),
commandPrefix: '/',
};
}
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
const enabledPlugins = readObjectRecord(settings.enabledPlugins);

View File

@@ -57,4 +57,12 @@ export class CodexSkillsProvider extends SkillsProvider {
return sources;
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.agents', 'skills'),
commandPrefix: '$',
};
}
}

View File

@@ -28,4 +28,12 @@ export class CursorSkillsProvider extends SkillsProvider {
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -33,4 +33,12 @@ export class GeminiSkillsProvider extends SkillsProvider {
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -12,6 +12,8 @@ import type {
McpScope,
McpTransport,
ProviderChangeActiveModelInput,
ProviderSkillCreateFile,
ProviderSkillCreateInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
@@ -179,6 +181,104 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
};
};
const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateInput => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const rawEntries = Array.isArray(body.entries)
? body.entries
: typeof body.content === 'string'
? [{
content: body.content,
directoryName: body.directoryName,
fileName: body.fileName,
files: body.files,
}]
: null;
if (!rawEntries || rawEntries.length === 0) {
throw new AppError('At least one skill entry is required.', {
code: 'PROVIDER_SKILLS_REQUIRED',
statusCode: 400,
});
}
const entries = rawEntries.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
throw new AppError(`Skill entry ${index + 1} must be an object.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const record = entry as Record<string, unknown>;
const content = typeof record.content === 'string' ? record.content : '';
const directoryName = readOptionalQueryString(record.directoryName);
const fileName = readOptionalQueryString(record.fileName);
const rawFiles = record.files;
if (!content.trim()) {
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
statusCode: 400,
});
}
if (rawFiles !== undefined && !Array.isArray(rawFiles)) {
throw new AppError(`Skill entry ${index + 1} files must be an array.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const files: ProviderSkillCreateFile[] | undefined = rawFiles?.map((file, fileIndex) => {
if (!file || typeof file !== 'object') {
throw new AppError(`Skill entry ${index + 1} file ${fileIndex + 1} must be an object.`, {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const fileRecord = file as Record<string, unknown>;
const relativePath = readOptionalQueryString(fileRecord.relativePath);
const fileContent = typeof fileRecord.content === 'string' ? fileRecord.content : null;
const encoding = fileRecord.encoding === 'utf8' || fileRecord.encoding === 'base64'
? fileRecord.encoding
: null;
if (!relativePath || fileContent === null || !encoding) {
throw new AppError(
`Skill entry ${index + 1} file ${fileIndex + 1} requires relativePath, content, and encoding.`,
{
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
},
);
}
return {
relativePath,
content: fileContent,
encoding,
};
});
return {
content,
directoryName,
fileName,
files,
};
});
return { entries };
};
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (
@@ -320,6 +420,16 @@ router.get(
}),
);
router.post(
'/:provider/skills',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const input = parseProviderSkillCreatePayload(req.body);
const skills = await providerSkillsService.addProviderSkills(provider, input);
res.json(createApiSuccessResponse({ provider, skills }));
}),
);
// ----------------- MCP routes -----------------
router.get(
'/:provider/mcp/servers',

View File

@@ -1,5 +1,9 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { ProviderSkill, ProviderSkillListOptions } from '@/shared/types.js';
import type {
ProviderSkill,
ProviderSkillCreateInput,
ProviderSkillListOptions,
} from '@/shared/types.js';
export const providerSkillsService = {
/**
@@ -12,4 +16,15 @@ export const providerSkillsService = {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.listSkills(options);
},
/**
* Writes one or more global skills for one provider.
*/
async addProviderSkills(
providerName: string,
input: ProviderSkillCreateInput,
): Promise<ProviderSkill[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.addSkills(input);
},
};

View File

@@ -1,20 +1,86 @@
import path from 'node:path';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import type { IProviderSkills } from '@/shared/interfaces.js';
import type {
LLMProvider,
ProviderSkillCreateInput,
ProviderSkill,
ProviderSkillListOptions,
ProviderSkillSource,
} from '@/shared/types.js';
import {
findProviderSkillMarkdownFiles,
readOptionalString,
readProviderSkillMarkdownDefinitionFromContent,
readProviderSkillMarkdownDefinition,
AppError,
} from '@/shared/utils.js';
const resolveWorkspacePath = (workspacePath?: string): string =>
path.resolve(workspacePath ?? process.cwd());
const stripMarkdownExtension = (value: string): string => value.replace(/\.md$/i, '');
const normalizeSkillDirectoryName = (value: string): string => (
value
.trim()
.replace(/[\\/]+/g, '-')
.replace(/[<>:"|?*\x00-\x1F]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^\.+|\.+$/g, '')
.replace(/^-+|-+$/g, '')
);
type PendingSkillInstall = {
skillDirectoryPath: string;
skillPath: string;
content: string;
supportingFiles: Array<{
targetPath: string;
content: string | Buffer;
}>;
skill: ProviderSkill;
};
const resolveSkillSupportingFilePath = (
skillDirectoryPath: string,
relativePath: string,
entryIndex: number,
): string => {
const normalizedRelativePath = relativePath.trim().replace(/\\/g, '/');
const pathSegments = normalizedRelativePath.split('/');
if (
!normalizedRelativePath
|| path.isAbsolute(normalizedRelativePath)
|| pathSegments.some((segment) => !segment || segment === '.' || segment === '..')
|| normalizedRelativePath.toLowerCase() === 'skill.md'
) {
throw new AppError(
`Skill entry ${entryIndex + 1} includes an invalid supporting file path "${relativePath}".`,
{
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
statusCode: 400,
},
);
}
const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath);
const resolvedFilePath = path.resolve(resolvedSkillDirectoryPath, ...pathSegments);
if (!resolvedFilePath.startsWith(`${resolvedSkillDirectoryPath}${path.sep}`)) {
throw new AppError(
`Skill entry ${entryIndex + 1} supporting files must stay inside the skill directory.`,
{
code: 'PROVIDER_SKILL_FILE_PATH_INVALID',
statusCode: 400,
},
);
}
return resolvedFilePath;
};
/**
* Shared skills provider for provider-specific skill source discovery.
*/
@@ -60,5 +126,119 @@ export abstract class SkillsProvider implements IProviderSkills {
return skills;
}
async addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]> {
const globalSkillSource = await this.getGlobalSkillSource();
if (!globalSkillSource) {
throw new AppError(`${this.provider} does not support managed global skills.`, {
code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED',
statusCode: 400,
});
}
if (!Array.isArray(input.entries) || input.entries.length === 0) {
throw new AppError('At least one skill entry is required.', {
code: 'PROVIDER_SKILLS_REQUIRED',
statusCode: 400,
});
}
const seenSkillPaths = new Set<string>();
const pendingInstalls: PendingSkillInstall[] = [];
for (const [index, entry] of input.entries.entries()) {
const content = typeof entry.content === 'string' ? entry.content.trim() : '';
if (!content) {
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
statusCode: 400,
});
}
const fileNameFallback = readOptionalString(entry.fileName);
const requestedDirectoryName = readOptionalString(entry.directoryName);
const fallbackSkillName = normalizeSkillDirectoryName(
requestedDirectoryName
?? (fileNameFallback ? stripMarkdownExtension(fileNameFallback) : `skill-${index + 1}`),
);
const definition = readProviderSkillMarkdownDefinitionFromContent(content, fallbackSkillName);
const resolvedDirectoryName = normalizeSkillDirectoryName(
requestedDirectoryName ?? definition.name,
);
if (!resolvedDirectoryName) {
throw new AppError(`Skill entry ${index + 1} must include a valid skill name.`, {
code: 'PROVIDER_SKILL_NAME_REQUIRED',
statusCode: 400,
});
}
const skillDirectoryPath = path.join(globalSkillSource.rootDir, resolvedDirectoryName);
const skillPath = path.join(skillDirectoryPath, 'SKILL.md');
const normalizedSkillPath = path.resolve(skillPath);
if (seenSkillPaths.has(normalizedSkillPath)) {
throw new AppError(`Duplicate skill target "${resolvedDirectoryName}" in one request.`, {
code: 'PROVIDER_SKILL_DUPLICATE_TARGET',
statusCode: 400,
});
}
seenSkillPaths.add(normalizedSkillPath);
const supportingFiles = (entry.files ?? []).map((file) => ({
targetPath: resolveSkillSupportingFilePath(skillDirectoryPath, file.relativePath, index),
content: file.encoding === 'base64'
? Buffer.from(file.content, 'base64')
: file.content,
}));
const seenSupportingPaths = new Set<string>();
for (const file of supportingFiles) {
if (seenSupportingPaths.has(file.targetPath)) {
throw new AppError(`Skill entry ${index + 1} includes a duplicate supporting file path.`, {
code: 'PROVIDER_SKILL_DUPLICATE_FILE',
statusCode: 400,
});
}
seenSupportingPaths.add(file.targetPath);
}
const command = globalSkillSource.commandForSkill
? globalSkillSource.commandForSkill(definition.name)
: `${globalSkillSource.commandPrefix ?? '/'}${definition.name}`;
pendingInstalls.push({
skillDirectoryPath,
skillPath,
content,
supportingFiles,
skill: {
provider: this.provider,
name: definition.name,
description: definition.description,
command,
scope: globalSkillSource.scope,
sourcePath: skillPath,
pluginName: globalSkillSource.pluginName,
pluginId: globalSkillSource.pluginId,
},
});
}
for (const install of pendingInstalls) {
// Replace the complete skill directory so removed scripts or assets do not remain stale.
await rm(install.skillDirectoryPath, { recursive: true, force: true });
await mkdir(install.skillDirectoryPath, { recursive: true });
await writeFile(install.skillPath, `${install.content}\n`, 'utf8');
for (const file of install.supportingFiles) {
await mkdir(path.dirname(file.targetPath), { recursive: true });
await writeFile(file.targetPath, file.content);
}
}
return pendingInstalls.map((install) => install.skill);
}
protected abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
return null;
}
}

View File

@@ -510,3 +510,195 @@ test('providerSkillsService lists gemini and cursor skills from their configured
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers managed global skill creation for providers that own a
* writable user skill directory.
*/
test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
const restoreHomeDir = patchHomeDir(tempRoot);
try {
const createdClaudeSkills = await providerSkillsService.addProviderSkills('claude', {
entries: [
{
directoryName: 'claude-global-dir',
content: '---\nname: claude-global\ndescription: Claude global skill\n---\n\nClaude body.\n',
},
],
});
const createdClaudeSkill = createdClaudeSkills[0];
assert.ok(createdClaudeSkill);
assert.equal(createdClaudeSkill.command, '/claude-global');
assert.equal(
createdClaudeSkill.sourcePath.endsWith(path.join('.claude', 'skills', 'claude-global-dir', 'SKILL.md')),
true,
);
assert.match(
await fs.readFile(createdClaudeSkill.sourcePath, 'utf8'),
/Claude body\./,
);
const createdCodexSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'uploaded-codex-folder',
fileName: 'SKILL.md',
content: '---\nname: codex-global\ndescription: Codex global skill\n---\n\nCodex body.\n',
files: [
{
relativePath: 'scripts/run.js',
content: Buffer.from('console.log("codex skill");\n').toString('base64'),
encoding: 'base64',
},
],
},
],
});
const createdCodexSkill = createdCodexSkills[0];
assert.ok(createdCodexSkill);
assert.equal(createdCodexSkill.command, '$codex-global');
assert.equal(
createdCodexSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'uploaded-codex-folder', 'SKILL.md')),
true,
);
assert.equal(
await fs.readFile(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js'), 'utf8'),
'console.log("codex skill");\n',
);
const fallbackNamedSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
fileName: 'fallback / skill.md',
content: '---\ndescription: Normalized fallback skill\n---\n\nFallback body.\n',
},
],
});
const fallbackNamedSkill = fallbackNamedSkills[0];
assert.ok(fallbackNamedSkill);
assert.equal(fallbackNamedSkill.name, 'fallback-skill');
assert.equal(fallbackNamedSkill.command, '$fallback-skill');
assert.equal(
fallbackNamedSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'fallback-skill', 'SKILL.md')),
true,
);
const replacedCodexSkills = await providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'uploaded-codex-folder',
content: '---\nname: replacement\ndescription: Replacement skill\n---\n\nReplacement body.\n',
},
],
});
assert.equal(replacedCodexSkills[0]?.command, '$replacement');
assert.match(await fs.readFile(createdCodexSkill.sourcePath, 'utf8'), /Replacement body\./);
await assert.rejects(
fs.stat(path.join(path.dirname(createdCodexSkill.sourcePath), 'scripts', 'run.js')),
{ code: 'ENOENT' },
);
const pendingBatchSkillPath = path.join(tempRoot, '.agents', 'skills', 'pending-batch', 'SKILL.md');
await assert.rejects(
providerSkillsService.addProviderSkills('codex', {
entries: [
{
directoryName: 'pending-batch',
content: '---\nname: pending-batch\n---\n\nPending body.\n',
},
{
directoryName: 'pending-batch',
content: '---\nname: duplicate-batch\n---\n\nDuplicate body.\n',
},
],
}),
/duplicate skill target/i,
);
await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
entries: [
{
directoryName: 'gemini-global-dir',
content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
},
],
});
const createdGeminiSkill = createdGeminiSkills[0];
assert.ok(createdGeminiSkill);
assert.equal(createdGeminiSkill.command, '/gemini-global');
assert.equal(
createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
true,
);
const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
entries: [
{
directoryName: 'cursor-global-dir',
content: '---\nname: cursor-global\ndescription: Cursor global skill\n---\n\nCursor body.\n',
},
],
});
const createdCursorSkill = createdCursorSkills[0];
assert.ok(createdCursorSkill);
assert.equal(createdCursorSkill.command, '/cursor-global');
assert.equal(
createdCursorSkill.sourcePath.endsWith(path.join('.cursor', 'skills', 'cursor-global-dir', 'SKILL.md')),
true,
);
const listedClaudeSkills = await providerSkillsService.listProviderSkills('claude');
assert.equal(listedClaudeSkills.some((skill) => skill.name === 'claude-global'), true);
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
await assert.rejects(
providerSkillsService.addProviderSkills('codex', {
entries: [
{
content: '---\nname: unsafe-skill\n---\n',
files: [
{
relativePath: '../outside.js',
content: '',
encoding: 'utf8',
},
],
},
],
}),
/invalid supporting file path/i,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* OpenCode reuses other providers' skill folders, so it should not accept
* direct skill writes through the managed provider endpoint.
*/
test('providerSkillsService rejects managed skill creation for opencode', { concurrency: false }, async () => {
await assert.rejects(
providerSkillsService.addProviderSkills('opencode', {
entries: [
{
directoryName: 'opencode-global-dir',
content: '---\nname: opencode-global\ndescription: Unsupported skill\n---\n\nOpenCode body.\n',
},
],
}),
/does not support managed global skills/i,
);
});

View File

@@ -171,6 +171,62 @@ function buildShellCommand(
return command;
}
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const resolvedKey = Object.keys(env).find((envKey) => envKey.toLowerCase() === key.toLowerCase());
return resolvedKey ? env[resolvedKey] : undefined;
}
function getPathEnvKey(env: NodeJS.ProcessEnv): string {
return Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
}
function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } {
const pathKey = getPathEnvKey(env);
const currentPath = env[pathKey];
if (!currentPath) {
return { key: pathKey, value: currentPath };
}
const delimiter = path.delimiter;
const pathEntries = currentPath.split(delimiter).filter(Boolean);
const npmPrefix = readEnvValue(env, 'npm_config_prefix');
const appData = readEnvValue(env, 'APPDATA');
const candidates = [
npmPrefix || '',
npmPrefix ? path.join(npmPrefix, 'bin') : '',
appData ? path.join(appData, 'npm') : '',
path.join(os.homedir(), 'AppData', 'Roaming', 'npm'),
path.join(os.homedir(), '.npm-global', 'bin'),
].filter(Boolean);
const normalizedPathEntries = pathEntries.map((entry) => os.platform() === 'win32' ? entry.toLowerCase() : entry);
const preferredEntries = candidates.filter((candidate, index) => {
const normalizedCandidate = os.platform() === 'win32' ? candidate.toLowerCase() : candidate;
return (
candidates.indexOf(candidate) === index &&
normalizedPathEntries.includes(normalizedCandidate)
);
});
if (preferredEntries.length === 0) {
return { key: pathKey, value: currentPath };
}
const normalizedPreferredEntries = preferredEntries.map((entry) =>
os.platform() === 'win32' ? entry.toLowerCase() : entry
);
const value = [
...preferredEntries,
...pathEntries.filter((entry) => {
const normalizedEntry = os.platform() === 'win32' ? entry.toLowerCase() : entry;
return !normalizedPreferredEntries.includes(normalizedEntry);
}),
].join(delimiter);
return { key: pathKey, value };
}
/**
* Handles websocket connections used by the standalone shell terminal UI.
*/
@@ -284,6 +340,7 @@ export function handleShellConnection(
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
const termCols = readNumber(data.cols, 80);
const termRows = readNumber(data.rows, 24);
const prioritizedPath = prioritizeUserNpmGlobalBin(process.env);
shellProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
@@ -292,6 +349,7 @@ export function handleShellConnection(
cwd: resolvedProjectPath,
env: {
...process.env,
[prioritizedPath.key]: prioritizedPath.value,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',

View File

@@ -12,6 +12,7 @@ import type {
ProviderModelsDefinition,
ProviderMcpServer,
ProviderSessionActiveModelChange,
ProviderSkillCreateInput,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
@@ -101,6 +102,15 @@ export interface IProviderSkills {
* Lists all skills visible to this provider for the optional workspace.
*/
listSkills(options?: ProviderSkillListOptions): Promise<ProviderSkill[]>;
/**
* Writes one or more global user-scoped skills for this provider.
*
* Implementations should install the supplied markdown entries into the
* provider's writable user skill folder and return the normalized skill
* records that were written.
*/
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
}
// ---------------------------

View File

@@ -320,6 +320,47 @@ export type ProviderSkillListOptions = {
workspacePath?: string;
};
/**
* One supporting file bundled with an uploaded provider skill.
*
* `relativePath` is resolved below the installed skill directory and must never
* be absolute or contain traversal segments. Text files may use `utf8`; binary
* scripts and assets should use `base64` so JSON transport does not corrupt
* their bytes.
*/
export type ProviderSkillCreateFile = {
relativePath: string;
content: string;
encoding: 'utf8' | 'base64';
};
/**
* One skill markdown payload submitted for provider-managed installation.
*
* `content` is the raw markdown body that will be written to `SKILL.md`.
* `directoryName` lets callers control the target folder name explicitly when
* they want stable filesystem paths that differ from the markdown front matter
* `name` field. `fileName` is optional upload metadata used only as a final
* fallback when no directory name or front matter name is present. `files`
* carries scripts, references, and other files from a complete skill folder.
*/
export type ProviderSkillCreateEntry = {
content: string;
directoryName?: string;
fileName?: string;
files?: ProviderSkillCreateFile[];
};
/**
* Shared input accepted by provider skill creation operations.
*
* The service layer batches multiple skill definitions in one request. Each
* entry can contain only markdown or a complete skill folder.
*/
export type ProviderSkillCreateInput = {
entries: ProviderSkillCreateEntry[];
};
/**
* Normalized skill record returned by provider skill adapters.
*

View File

@@ -957,9 +957,25 @@ export async function readProviderSkillMarkdownDefinition(
skillPath: string,
): Promise<{ name: string; description: string }> {
const content = await readFile(skillPath, 'utf8');
return readProviderSkillMarkdownDefinitionFromContent(
content,
path.basename(path.dirname(skillPath)),
);
}
/**
* Reads the `name` and `description` fields from raw skill markdown content.
*
* This keeps filesystem discovery and newly uploaded skill creation aligned on
* the same front matter parsing rules. `fallbackName` is used when the markdown
* omits a `name` field so callers still get a stable, non-empty skill id.
*/
export function readProviderSkillMarkdownDefinitionFromContent(
content: string,
fallbackName: string,
): { name: string; description: string } {
const parsed = parseFrontMatter(content);
const data = readObjectRecord(parsed.data) ?? {};
const fallbackName = path.basename(path.dirname(skillPath));
return {
name: readOptionalString(data.name) ?? fallbackName,

224
server/voice-proxy.js Normal file
View File

@@ -0,0 +1,224 @@
// Optional voice proxy — forwards STT/TTS to an OpenAI-compatible audio backend.
//
// The backend is whatever the user points at: OpenAI, Groq, or a local server
// (LocalAI / Speaches / Kokoro-FastAPI / openedai-speech / etc.). It must expose the
// standard OpenAI audio endpoints:
// POST {base}/audio/transcriptions (multipart 'file' + 'model') -> { text }
// POST {base}/audio/speech ({ model, voice, input }) -> audio bytes
//
// Config is resolved per-request from headers (set by the client's voice settings),
// falling back to server env defaults. Mounted at /api/voice behind authenticateToken.
import { Readable } from 'node:stream';
import express from 'express';
const ENV = {
baseUrl: (process.env.VOICE_API_BASE_URL || '').replace(/\/$/, ''),
apiKey: process.env.VOICE_API_KEY || '',
sttModel: process.env.VOICE_STT_MODEL || 'whisper-1',
ttsModel: process.env.VOICE_TTS_MODEL || 'tts-1',
ttsVoice: process.env.VOICE_TTS_VOICE || 'alloy',
};
/**
* Resolve the voice backend config for a request. Client headers (set from the
* user's in-app voice settings) take precedence over the server env defaults.
* @param {import('express').Request} req
* @returns {{baseUrl: string, apiKey: string, sttModel: string, ttsModel: string, ttsVoice: string, ttsFormat: string}}
*/
function resolveConfig(req) {
const h = req.headers;
return {
// Security: do not allow clients to control the outbound backend host.
// Always use the server-side configured base URL.
baseUrl: ENV.baseUrl,
apiKey: String(h['x-voice-api-key'] || '') || ENV.apiKey,
sttModel: String(h['x-voice-stt-model'] || '') || ENV.sttModel,
ttsModel: String(h['x-voice-tts-model'] || '') || ENV.ttsModel,
ttsVoice: String(h['x-voice-tts-voice'] || '') || ENV.ttsVoice,
ttsFormat: String(h['x-voice-tts-format'] || '').trim(),
};
}
const router = express.Router();
// Generous by default — local TTS can synthesize long messages at ~real-time on CPU.
// Guard against a non-numeric/zero override that would make setTimeout fire immediately.
const DEFAULT_VOICE_TIMEOUT_MS = 300000;
const _parsedTimeout = Number(process.env.VOICE_TIMEOUT_MS);
const VOICE_TIMEOUT_MS = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0
? _parsedTimeout
: DEFAULT_VOICE_TIMEOUT_MS;
/**
* fetch() with an AbortController timeout so a stalled backend can't hold the
* request open indefinitely. Aborts after VOICE_TIMEOUT_MS.
* @param {string} url
* @param {RequestInit} [options]
* @returns {Promise<Response>}
*/
async function fetchWithTimeout(url, options = {}) {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol) || !isAllowedBackendUrl(parsed.origin)) {
throw new Error('Blocked outbound voice backend URL');
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), VOICE_TIMEOUT_MS);
try {
return await fetch(parsed.toString(), { redirect: 'manual', ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
/**
* Turn a backend fetch failure into a clear, actionable client response:
* 504 on timeout (AbortError), 502 otherwise.
* @param {import('express').Response} res
* @param {Error} e
*/
function backendError(res, e) {
if (e && e.name === 'AbortError') {
return res.status(504).json({
error: `Voice backend timed out after ${Math.round(VOICE_TIMEOUT_MS / 1000)}s. Check your voice backend.`,
});
}
return res.status(502).json({ error: `Voice backend unreachable: ${e.message}` });
}
/**
* SSRF guard for the user-configurable backend URL: allow http/https only and
* block the link-local / cloud-metadata range (169.254.x). localhost and private
* ranges are allowed on purpose so users can point at a local voice server
* (LocalAI, Speaches, Kokoro-FastAPI, etc.).
* @param {string} raw
* @returns {boolean}
*/
function isAllowedBackendUrl(raw) {
let u;
try {
u = new URL(raw);
} catch {
return false;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
if (u.hostname === '169.254.169.254' || u.hostname.startsWith('169.254.')) return false;
return true;
}
/**
* Relay an upstream (backend) error to the client without making an upstream
* 401/403 look like the user's own app login failed.
* @param {import('express').Response} res
* @param {number} status
* @param {string} [text]
*/
function upstreamError(res, status, text) {
if (status === 401 || status === 403) {
return res.status(502).json({ error: 'Voice backend rejected the request (check the API key).' });
}
return res.status(status).json({ error: text || 'voice backend error' });
}
let _upload = null;
/**
* Lazily build a memory-storage multer instance (25 MB cap) for audio uploads,
* so multer is only imported when the voice feature is actually used.
* @returns {Promise<import('multer').Multer>}
*/
async function getUpload() {
if (!_upload) {
const multer = (await import('multer')).default;
_upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
}
return _upload;
}
/**
* Build the Authorization header for the backend, or an empty object when no
* key is configured (e.g. a local server that needs none).
* @param {string} apiKey
* @returns {Record<string, string>}
*/
function authHeader(apiKey) {
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
}
/**
* GET /api/voice/health -> { configured } (true when a backend base URL is set).
*/
router.get('/health', (req, res) => {
res.json({ configured: Boolean(resolveConfig(req).baseUrl) });
});
/**
* POST /api/voice/transcribe (multipart 'audio') -> { text }.
* Forwards the uploaded audio to the backend's /audio/transcriptions endpoint.
*/
router.post('/transcribe', async (req, res) => {
const cfg = resolveConfig(req);
if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' });
if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' });
const upload = await getUpload();
upload.single('audio')(req, res, async (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No audio uploaded' });
try {
const fd = new FormData();
fd.append(
'file',
new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' }),
req.file.originalname || 'recording.webm',
);
fd.append('model', cfg.sttModel);
const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/transcriptions`, {
method: 'POST',
headers: authHeader(cfg.apiKey),
body: fd,
});
const text = await r.text();
if (!r.ok) return upstreamError(res, r.status, text);
let data;
try { data = JSON.parse(text); } catch { data = { text }; }
res.json({ text: data.text ?? '' });
} catch (e) {
backendError(res, e);
}
});
});
/**
* POST /api/voice/tts { text } -> audio bytes.
* Forwards the text to the backend's /audio/speech endpoint and streams the audio back.
*/
router.post('/tts', async (req, res) => {
const cfg = resolveConfig(req);
if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' });
if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' });
const text = req.body?.text;
if (typeof text !== 'string' || !text.trim()) return res.status(400).json({ error: 'text required' });
try {
const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/speech`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader(cfg.apiKey) },
body: JSON.stringify({
model: cfg.ttsModel,
voice: cfg.ttsVoice,
input: text,
...(cfg.ttsFormat ? { response_format: cfg.ttsFormat } : {}),
}),
});
if (!r.ok) {
const errText = await r.text().catch(() => 'tts failed');
return upstreamError(res, r.status, errText);
}
res.setHeader('Content-Type', r.headers.get('content-type') || 'audio/mpeg');
res.setHeader('Cache-Control', 'no-store');
if (!r.body) return res.end();
Readable.fromWeb(r.body).on('error', (error) => res.destroy(error)).pipe(res);
} catch (e) {
backendError(res, e);
}
});
export default router;

View File

@@ -775,6 +775,17 @@ export function useChatComposerState({
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
// A voice transcript either fills the input (to edit before sending) or, when the
// user tapped "stop and send", is submitted straight away. Mirror the value into
// inputValueRef synchronously so handleSubmit reads the new text, not the stale state.
const handleVoiceTranscript = useCallback((text: string, send?: boolean) => {
const base = inputValueRef.current.trim();
const next = base ? `${base} ${text}` : text;
setInput(next);
inputValueRef.current = next;
if (send) handleSubmitRef.current?.(createFakeSubmitEvent());
}, [setInput]);
useEffect(() => {
inputValueRef.current = input;
}, [input]);
@@ -1013,6 +1024,7 @@ export function useChatComposerState({
isDragActive,
openImagePicker: open,
handleSubmit,
handleVoiceTranscript,
handleInputChange,
handleKeyDown,
handlePaste,

View File

@@ -114,7 +114,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [providerModelsLoading, setProviderModelsLoading] = useState(true);
const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false);
const lastProviderRef = useRef(provider);
const providerModelsRequestIdRef = useRef(0);
const setStoredProviderModel = useCallback((targetProvider: LLMProvider, model: string) => {
@@ -344,14 +343,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('selected-provider', selectedSession.__provider);
}, [provider, selectedSession]);
useEffect(() => {
if (lastProviderRef.current === provider) {
return;
}
setPendingPermissionRequests([]);
lastProviderRef.current = provider;
}, [provider]);
// Permission prompts belong to a session, not to the transient provider
// selection that is synchronized after navigation.
useEffect(() => {
setPendingPermissionRequests((previous) =>
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),

View File

@@ -1,20 +1,29 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { ServerEvent } from '../../../contexts/WebSocketContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import { playChatCompletionSound, playNotificationSound } from '../../../utils/notificationSound';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
const isActionablePermissionRequest = (request: { toolName?: unknown } | null | undefined): boolean => {
return request?.toolName !== 'ExitPlanMode' && request?.toolName !== 'exit_plan_mode';
};
const hasActionablePermissionRequests = (requests: Array<{ toolName?: unknown }> | null | undefined): boolean => {
return Array.isArray(requests) && requests.some((request) => isActionablePermissionRequest(request));
};
interface UseChatRealtimeHandlersArgs {
subscribe: (listener: (event: ServerEvent) => void) => () => void;
provider: LLMProvider;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
pendingPermissionRequests: PendingPermissionRequest[];
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
@@ -52,6 +61,7 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
@@ -62,13 +72,29 @@ export function useChatRealtimeHandlers({
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
// Session switches can send `chat.subscribe` before this effect has a chance
// to rebind the websocket listener. Read the visible session id from a ref
// so a fast `chat_subscribed` ack is matched against the current view, not
// the previous render's closed-over selection.
const activeViewSessionIdRef = useRef<string | null>(selectedSession?.id || currentSessionId || null);
activeViewSessionIdRef.current = selectedSession?.id || currentSessionId || null;
// Keep the latest pending-permission snapshot available to the websocket
// listener so back-to-back permission events can dedupe and re-arm the
// notification sound before React finishes a rerender.
const pendingPermissionRequestsRef = useRef(pendingPermissionRequests);
useEffect(() => {
pendingPermissionRequestsRef.current = pendingPermissionRequests;
}, [pendingPermissionRequests]);
useEffect(() => {
const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) {
return;
}
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
const activeViewSessionId = activeViewSessionIdRef.current;
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
// Record replay progress for every sequenced live event.
@@ -101,7 +127,16 @@ export function useChatRealtimeHandlers({
const isViewedSession = sid === activeViewSessionId;
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
const nextPendingPermissionRequests = msg.pendingPermissions as PendingPermissionRequest[];
const hadActionablePermissionRequests = hasActionablePermissionRequests(pendingPermissionRequestsRef.current);
const hasPendingActionablePermissionRequests = hasActionablePermissionRequests(nextPendingPermissionRequests);
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
if (hasPendingActionablePermissionRequests && !hadActionablePermissionRequests) {
void playNotificationSound();
}
}
return;
}
@@ -203,6 +238,7 @@ export function useChatRealtimeHandlers({
// hides it immediately and atomically.
onSessionIdle?.(sid);
if (sid === activeViewSessionId) {
pendingPermissionRequestsRef.current = [];
setPendingPermissionRequests([]);
}
@@ -234,10 +270,14 @@ export function useChatRealtimeHandlers({
case 'permission_request': {
if (!msg.requestId) break;
if (isActionablePermissionRequest({ toolName: msg.toolName })) {
void playNotificationSound();
}
if (sid === activeViewSessionId) {
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
const previousPendingPermissionRequests = pendingPermissionRequestsRef.current;
if (!previousPendingPermissionRequests.some((request) => request.requestId === msg.requestId)) {
const nextPendingPermissionRequests = [...previousPendingPermissionRequests, {
requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input,
@@ -245,7 +285,10 @@ export function useChatRealtimeHandlers({
sessionId: sid || null,
receivedAt: new Date(),
}];
});
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
}
}
if (sid) {
onSessionProcessing?.(sid);
@@ -255,7 +298,12 @@ export function useChatRealtimeHandlers({
case 'permission_cancelled': {
if (msg.requestId && sid === activeViewSessionId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
const nextPendingPermissionRequests = pendingPermissionRequestsRef.current.filter(
(request: PendingPermissionRequest) => request.requestId !== msg.requestId,
);
pendingPermissionRequestsRef.current = nextPendingPermissionRequests;
setPendingPermissionRequests(nextPendingPermissionRequests);
}
break;
}
@@ -286,6 +334,7 @@ export function useChatRealtimeHandlers({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,

View File

@@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from 'react';
import { voicePlayer, voiceId, type VoiceSnapshot } from '../../../lib/voicePlayer';
export type TtsState = VoiceSnapshot['state'];
/**
* Thin adapter over the app-level voicePlayer. Playback lives outside React (see
* lib/voicePlayer), so switching chats or re-rendering a message no longer cuts the
* audio off. This hook just reflects the player's state for one message and forwards taps.
*/
export function useTts(getText: () => string) {
const content = getText();
const id = voiceId(content);
const [snap, setSnap] = useState<VoiceSnapshot>(() => voicePlayer.getSnapshot(id));
useEffect(() => {
const update = () =>
setSnap((prev) => {
const next = voicePlayer.getSnapshot(id);
return prev.state === next.state && prev.error === next.error ? prev : next;
});
update();
return voicePlayer.subscribe(update);
}, [id]);
const toggle = useCallback(() => {
voicePlayer.unlock(); // synchronous, within the click gesture (iOS)
voicePlayer.toggle(content);
}, [content]);
return { state: snap.state, toggle, error: snap.error };
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { readVoiceConfig, VOICE_CONFIG_SYNC_EVENT } from '../../../hooks/useVoiceConfig';
// Voice UI is gated on the `voiceEnabled` UI preference (toggled in Quick Settings /
// the Settings modal) and a configured voice backend.
const STORAGE_KEY = 'uiPreferences';
const SYNC_EVENT = 'ui-preferences:sync';
let healthRequest: Promise<boolean> | null = null;
function checkVoiceHealth(): Promise<boolean> {
if (healthRequest) return healthRequest;
const request = authenticatedFetch('/api/voice/health')
.then(async (response) => {
if (!response.ok) throw new Error(`Voice health check failed (${response.status})`);
const data = await response.json();
return data?.configured === true;
})
.finally(() => {
healthRequest = null;
});
healthRequest = request;
return request;
}
function readVoiceEnabled(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
const parsed = JSON.parse(raw);
return parsed?.voiceEnabled === true || parsed?.voiceEnabled === 'true';
} catch {
return false;
}
}
export function useVoiceAvailable(): boolean {
const [enabled, setEnabled] = useState<boolean>(() =>
typeof window === 'undefined' ? false : readVoiceEnabled(),
);
const [available, setAvailable] = useState(false);
useEffect(() => {
const update = () => setEnabled(readVoiceEnabled());
window.addEventListener('storage', update);
window.addEventListener(SYNC_EVENT, update as EventListener);
return () => {
window.removeEventListener('storage', update);
window.removeEventListener(SYNC_EVENT, update as EventListener);
};
}, []);
useEffect(() => {
let active = true;
let requestId = 0;
const check = async () => {
if (!enabled) {
setAvailable(false);
return;
}
if (readVoiceConfig().baseUrl.trim()) {
setAvailable(true);
return;
}
const id = ++requestId;
try {
const result = await checkVoiceHealth();
if (active && id === requestId) setAvailable(result);
} catch {
if (active && id === requestId) setAvailable(false);
}
};
void check();
window.addEventListener(VOICE_CONFIG_SYNC_EVENT, check);
return () => {
active = false;
window.removeEventListener(VOICE_CONFIG_SYNC_EVENT, check);
};
}, [enabled]);
return enabled && available;
}

View File

@@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { transcribeVoice } from '../../../lib/voiceApi';
// Mobile-safe recording: iOS Safari 18.4+ supports webm/opus; older iOS needs mp4.
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
];
function pickMime(): string {
for (const t of MIME_CANDIDATES) {
try {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) return t;
} catch {
/* isTypeSupported can throw on some iOS versions */
}
}
return '';
}
export type VoiceInputState = 'idle' | 'recording' | 'transcribing';
/**
* Push-to-talk dictation. Records the mic, uploads to /api/voice/transcribe
* (an OpenAI-compatible speech-to-text backend via the Express proxy), and
* returns the transcript through onTranscript.
*/
export function useVoiceInput(
onTranscript: (text: string, send?: boolean) => void,
onError?: (msg: string) => void,
) {
const [state, setState] = useState<VoiceInputState>('idle');
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const cancelledRef = useRef(false);
const startingRef = useRef(false);
// Whether the in-progress stop should auto-send the transcript (vs just fill the box).
const sendRef = useRef(false);
const stopTracks = () => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
// Stop the mic if the component unmounts mid-recording.
useEffect(() => {
cancelledRef.current = false;
return () => {
cancelledRef.current = true;
startingRef.current = false;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
};
}, []);
const start = useCallback(async () => {
if (startingRef.current || (recorderRef.current && recorderRef.current.state !== 'inactive')) return;
startingRef.current = true;
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
});
if (cancelledRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
const mimeType = pickMime();
const rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
recorderRef.current = rec;
chunksRef.current = [];
rec.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
rec.onstop = async () => {
stopTracks();
if (cancelledRef.current) return;
// Capture and clear the send intent for this stop before any async work.
const shouldSend = sendRef.current;
sendRef.current = false;
const type = rec.mimeType || 'audio/webm';
const blob = new Blob(chunksRef.current, { type });
if (blob.size < 800) {
setState('idle');
onError?.('Recording too short');
return;
}
setState('transcribing');
try {
const ext = type.includes('mp4') ? 'm4a' : type.includes('ogg') ? 'ogg' : 'webm';
const res = await transcribeVoice(blob, `recording.${ext}`);
if (!res.ok) throw new Error(`transcribe ${res.status}`);
const data = await res.json();
if (cancelledRef.current) return;
const text = String(data?.text || '').trim();
if (text) onTranscript(text, shouldSend);
else onError?.('No speech detected');
} catch (e) {
if (!cancelledRef.current) {
onError?.(`Transcription failed: ${e instanceof Error ? e.message : String(e)}`);
}
} finally {
if (!cancelledRef.current) setState('idle');
}
};
rec.start();
setState('recording');
} catch (e) {
recorderRef.current = null;
stopTracks();
if (cancelledRef.current) return;
const err = e as { name?: string; message?: string };
let msg = `Mic error: ${err?.message || e}`;
if (err?.name === 'NotAllowedError') msg = 'Microphone access denied.';
else if (err?.name === 'NotFoundError') msg = 'No microphone found.';
onError?.(msg);
setState('idle');
} finally {
startingRef.current = false;
}
}, [onTranscript, onError]);
// Stop recording. Pass { send: true } to auto-send the transcript once it's ready.
// Guard on the recorder's own state (not React state) so a double tap, or the mic
// and Send buttons both firing, can't call stop() on an already-inactive recorder.
const stop = useCallback((opts?: { send?: boolean }) => {
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') {
sendRef.current = opts?.send ?? false;
rec.stop();
}
}, []);
const toggle = useCallback(() => {
if (state === 'recording') stop();
else if (state === 'idle') start();
}, [state, start, stop]);
return { state, toggle, stop };
}

View File

@@ -11,7 +11,7 @@ export function decodeHtmlEntities(text: string) {
export function normalizeInlineCodeFences(text: string) {
if (!text || typeof text !== 'string') return text;
try {
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
return text.replace(/```[ \t]*([^\n\r]+?)[ \t]*```/g, '`$1`');
} catch {
return text;
}

View File

@@ -173,6 +173,7 @@ function ChatInterface({
isDragActive,
openImagePicker,
handleSubmit,
handleVoiceTranscript,
handleInputChange,
handleKeyDown,
handlePaste,
@@ -239,6 +240,7 @@ function ChatInterface({
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
@@ -405,6 +407,7 @@ function ChatInterface({
renderInputWithMentions={renderInputWithMentions}
textareaRef={textareaRef}
input={input}
onVoiceTranscript={handleVoiceTranscript}
onInputChange={handleInputChange}
onTextareaClick={handleTextareaClick}
onTextareaKeyDown={handleKeyDown}

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
ChangeEvent,
ClipboardEvent,
@@ -9,8 +10,10 @@ import type {
RefObject,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import {
@@ -27,6 +30,7 @@ import {
import CommandMenu from './CommandMenu';
import ActivityIndicator from './ActivityIndicator';
import ImageAttachment from './ImageAttachment';
import VoiceInputButton from './VoiceInputButton';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import TokenUsageSummary from './TokenUsageSummary';
@@ -89,6 +93,7 @@ interface ChatComposerProps {
renderInputWithMentions: (text: string) => ReactNode;
textareaRef: RefObject<HTMLTextAreaElement>;
input: string;
onVoiceTranscript?: (text: string, send?: boolean) => void;
onInputChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
onTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => void;
onTextareaKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
@@ -142,6 +147,7 @@ export default function ChatComposer({
renderInputWithMentions,
textareaRef,
input,
onVoiceTranscript,
onInputChange,
onTextareaClick,
onTextareaKeyDown,
@@ -154,6 +160,28 @@ export default function ChatComposer({
sendByCtrlEnter,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
// Voice state is hosted here (not in the mic button) so the main Send button can stop
// recording and send the transcript in one tap, the way the mic button drops it in the box.
const voiceAvailable = useVoiceAvailable();
const [voiceError, setVoiceError] = useState<string | null>(null);
const voiceErrorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleVoiceError = useCallback((msg: string) => {
setVoiceError(msg);
if (voiceErrorTimer.current) clearTimeout(voiceErrorTimer.current);
voiceErrorTimer.current = setTimeout(() => setVoiceError(null), 4000);
}, []);
useEffect(() => () => {
if (voiceErrorTimer.current) clearTimeout(voiceErrorTimer.current);
}, []);
const noopTranscript = useCallback(() => {}, []);
const { state: voiceState, toggle: voiceToggle, stop: voiceStop } = useVoiceInput(
onVoiceTranscript ?? noopTranscript,
handleVoiceError,
);
const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing';
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
@@ -309,6 +337,10 @@ export default function ChatComposer({
<ImageIcon />
</PromptInputButton>
{onVoiceTranscript && voiceAvailable && (
<VoiceInputButton state={voiceState} onToggle={voiceToggle} errorMsg={voiceError} />
)}
<button
type="button"
onClick={onModeSwitch}
@@ -387,10 +419,21 @@ export default function ChatComposer({
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div>
<PromptInputSubmit
onClick={isLoading ? onAbortSession : undefined}
disabled={!isLoading && !input.trim()}
onClick={
isLoading
? onAbortSession
: isRecording
? (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
voiceStop({ send: true });
}
: undefined
}
disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()}
className="h-10 w-10 sm:h-10 sm:w-10"
/>
>
{isTranscribing ? <Loader2 className="h-4 w-4 animate-spin" /> : undefined}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>

View File

@@ -15,6 +15,7 @@ import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../share
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
import MessageSpeakControl from './MessageSpeakControl';
type DiffLine = {
type: string;
@@ -415,6 +416,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
{shouldShowAssistantCopyControl && (
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
)}
{shouldShowAssistantCopyControl && (
<MessageSpeakControl content={assistantCopyContent} />
)}
{!isGrouped && <span>{formattedTime}</span>}
</div>
)}

View File

@@ -0,0 +1,44 @@
import { Volume2, Loader2, Square } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTts } from '../../hooks/useTts';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
// Tap-to-speak button beside the copy control on assistant messages.
// Renders nothing unless the optional voice feature is enabled.
const MessageSpeakControl = ({ content }: { content: string }) => {
const { t } = useTranslation('chat');
const available = useVoiceAvailable();
const { state, toggle, error } = useTts(() => content);
if (!available) return null;
const title =
state === 'playing' ? t('voice.stopSpeaking') : state === 'loading' ? t('voice.loading') : t('voice.speak');
return (
<span className="relative inline-flex">
{error && (
<span className="absolute bottom-full left-1/2 z-10 mb-1 max-w-[240px] -translate-x-1/2 whitespace-normal rounded bg-red-600 px-2 py-1 text-center text-xs text-white shadow-lg">
{error}
</span>
)}
<button
type="button"
onClick={toggle}
title={title}
aria-label={title}
className="inline-flex items-center gap-1 rounded px-1 py-0.5 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
{state === 'playing' ? (
<Square className="h-3.5 w-3.5" />
) : state === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Volume2 className="h-3.5 w-3.5" />
)}
</button>
</span>
);
};
export default MessageSpeakControl;

View File

@@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { Mic, Square, Loader2 } from 'lucide-react';
import { PromptInputButton } from '../../../../shared/view/ui';
import type { VoiceInputState } from '../../hooks/useVoiceInput';
type Props = {
state: VoiceInputState;
onToggle: () => void;
errorMsg?: string | null;
};
// Push-to-talk mic button (presentational). Recording state and the stop-and-send action
// are owned by the composer so the main Send button can drive them too. This button just
// starts recording and, while recording, stops and drops the transcript into the input box.
export default function VoiceInputButton({ state, onToggle, errorMsg }: Props) {
const { t } = useTranslation('chat');
const icon =
state === 'recording' ? (
<Square className="text-red-500" />
) : state === 'transcribing' ? (
<Loader2 className="animate-spin" />
) : (
<Mic />
);
return (
<span className="relative inline-flex">
{errorMsg && (
<span className="absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-red-600 px-2 py-1 text-xs text-white shadow-lg">
{errorMsg}
</span>
)}
<PromptInputButton
tooltip={{ content: state === 'recording' ? t('voice.stopRecording') : t('voice.input') }}
onClick={(e: { preventDefault: () => void }) => {
e.preventDefault();
onToggle();
}}
>
{icon}
</PromptInputButton>
</span>
);
}

View File

@@ -4,6 +4,7 @@ import {
Eye,
Languages,
Maximize2,
Mic,
} from 'lucide-react';
import type { PreferenceToggleItem } from './types';
@@ -54,4 +55,9 @@ export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
labelKey: 'quickSettings.sendByCtrlEnter',
icon: Languages,
},
{
key: 'voiceEnabled',
labelKey: 'quickSettings.voiceEnabled',
icon: Mic,
},
];

View File

@@ -6,7 +6,8 @@ export type PreferenceToggleKey =
| 'showRawParameters'
| 'showThinking'
| 'autoScrollToBottom'
| 'sendByCtrlEnter';
| 'sendByCtrlEnter'
| 'voiceEnabled';
export type QuickSettingsPreferences = Record<PreferenceToggleKey, boolean>;

View File

@@ -27,12 +27,14 @@ export default function QuickSettingsPanelView() {
showThinking: preferences.showThinking,
autoScrollToBottom: preferences.autoScrollToBottom,
sendByCtrlEnter: preferences.sendByCtrlEnter,
voiceEnabled: preferences.voiceEnabled,
}), [
preferences.autoExpandTools,
preferences.autoScrollToBottom,
preferences.sendByCtrlEnter,
preferences.showRawParameters,
preferences.showThinking,
preferences.voiceEnabled,
]);
const handlePreferenceChange = useCallback(

View File

@@ -3,9 +3,9 @@ import type { Dispatch, SetStateAction } from 'react';
import type { LLMProvider } from '../../../types/app';
import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null;
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';

View File

@@ -1,11 +1,13 @@
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
import { Button } from '../../../shared/view/ui';
import SettingsSidebar from '../view/SettingsSidebar';
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import VoiceSettingsTab from '../view/tabs/VoiceSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import BrowserUseSettingsTab from '../view/tabs/browser-use-settings/BrowserUseSettingsTab';
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
@@ -101,12 +103,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
</div>
{/* Body: sidebar + content */}
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className="flex min-h-0 min-w-0 flex-1 flex-col md:flex-row">
<SettingsSidebar activeTab={activeTab} onChange={setActiveTab} />
{/* Content */}
<main className="flex-1 overflow-y-auto">
<div key={activeTab} className="settings-content-enter space-y-6 p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
<main className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div key={activeTab} className="settings-content-enter min-w-0 space-y-6 overflow-x-hidden p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
{activeTab === 'appearance' && (
<AppearanceSettingsTab
projectSortOrder={projectSortOrder}
@@ -156,6 +158,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'api' && <CredentialsSettingsTab />}
{activeTab === 'voice' && <VoiceSettingsTab />}
{activeTab === 'plugins' && <PluginSettingsTab />}
{activeTab === 'about' && <AboutTab />}

View File

@@ -1,5 +1,6 @@
import { Bell, Bot, GitBranch, Info, Key, ListChecks, MonitorPlay, Palette, Puzzle } from 'lucide-react';
import { Bell, Bot, GitBranch, Info, Key, ListChecks, Mic, MonitorPlay, Palette, Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import { PillBar, Pill } from '../../../shared/view/ui';
import type { SettingsMainTab } from '../types/types';
@@ -20,6 +21,7 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'appearance', labelKey: 'mainTabs.appearance', icon: Palette },
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
{ id: 'voice', labelKey: 'mainTabs.voice', icon: Mic },
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
{ id: 'browser', labelKey: 'mainTabs.browser', icon: MonitorPlay },
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },

View File

@@ -0,0 +1,88 @@
import type { InputHTMLAttributes } from 'react';
import { useTranslation } from 'react-i18next';
import SettingsSection from '../SettingsSection';
import SettingsToggle from '../SettingsToggle';
import { useUiPreferences } from '../../../../hooks/useUiPreferences';
import { useVoiceConfig } from '../../../../hooks/useVoiceConfig';
const inputClass =
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring';
function Field({ label, ...props }: { label: string } & InputHTMLAttributes<HTMLInputElement>) {
return (
<label className="block space-y-1">
<span className="text-sm font-medium text-foreground">{label}</span>
<input className={inputClass} {...props} />
</label>
);
}
export default function VoiceSettingsTab() {
const { t } = useTranslation('settings');
const { preferences, setPreference } = useUiPreferences();
const { config, update } = useVoiceConfig();
return (
<div className="space-y-8">
<SettingsSection title={t('voiceSettings.title')} description={t('voiceSettings.description')}>
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<div className="pr-3">
<div className="text-sm font-medium text-foreground">{t('voiceSettings.enable')}</div>
<div className="text-xs text-muted-foreground">{t('voiceSettings.enableDescription')}</div>
</div>
<SettingsToggle
checked={preferences.voiceEnabled}
onChange={(v) => setPreference('voiceEnabled', v)}
ariaLabel={t('voiceSettings.enable')}
/>
</div>
</SettingsSection>
<SettingsSection title={t('voiceSettings.backendTitle')} description={t('voiceSettings.backendDescription')}>
<div className="space-y-4">
<Field
label={t('voiceSettings.baseUrl')}
placeholder="https://api.openai.com/v1"
value={config.baseUrl}
onChange={(e) => update({ baseUrl: e.target.value })}
/>
<Field
label={t('voiceSettings.apiKey')}
type="password"
autoComplete="off"
placeholder="sk-…"
value={config.apiKey}
onChange={(e) => update({ apiKey: e.target.value })}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field
label={t('voiceSettings.sttModel')}
placeholder="whisper-1"
value={config.sttModel}
onChange={(e) => update({ sttModel: e.target.value })}
/>
<Field
label={t('voiceSettings.ttsModel')}
placeholder="tts-1"
value={config.ttsModel}
onChange={(e) => update({ ttsModel: e.target.value })}
/>
<Field
label={t('voiceSettings.voice')}
placeholder="alloy"
value={config.ttsVoice}
onChange={(e) => update({ ttsVoice: e.target.value })}
/>
<Field
label={t('voiceSettings.format')}
placeholder="mp3"
value={config.ttsFormat}
onChange={(e) => update({ ttsFormat: e.target.value })}
/>
</div>
<p className="text-xs text-muted-foreground">{t('voiceSettings.note')}</p>
</div>
</SettingsSection>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { AgentCategory, AgentProvider } from '../../../types/types';
@@ -22,6 +22,11 @@ export default function AgentsSettingsTab({
}: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
const visibleCategories = useMemo<AgentCategory[]>(() => (
selectedAgent === 'opencode'
? ['account', 'permissions', 'mcp']
: ['account', 'permissions', 'mcp', 'skills']
), [selectedAgent]);
const visibleAgents = useMemo<AgentProvider[]>(() => {
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
@@ -57,8 +62,14 @@ export default function AgentsSettingsTab({
providerAuthStatus.opencode,
]);
useEffect(() => {
if (!visibleCategories.includes(selectedCategory)) {
setSelectedCategory(visibleCategories[0] ?? 'account');
}
}, [selectedCategory, visibleCategories]);
return (
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] min-w-0 flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<AgentSelectorSection
agents={visibleAgents}
selectedAgent={selectedAgent}
@@ -66,8 +77,10 @@ export default function AgentsSettingsTab({
agentContextById={agentContextById}
/>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<AgentCategoryTabsSection
categories={visibleCategories}
selectedAgent={selectedAgent}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>

View File

@@ -1,6 +1,8 @@
import type { AgentCategoryContentSectionProps } from '../types';
import type { McpProject } from '../../../../../mcp/types';
import { McpServers } from '../../../../../mcp';
import type { SkillsProject } from '../../../../../skills/types';
import { ProviderSkills } from '../../../../../skills';
import AccountContent from './content/AccountContent';
import PermissionsContent from './content/PermissionsContent';
@@ -18,7 +20,7 @@ export default function AgentCategoryContentSection({
projects,
}: AgentCategoryContentSectionProps) {
return (
<div className="flex-1 overflow-y-auto p-3 md:p-4">
<div className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden p-3 md:p-4">
{selectedCategory === 'account' && (
<AccountContent
agent={selectedAgent}
@@ -84,6 +86,18 @@ export default function AgentCategoryContentSection({
}))}
/>
)}
{selectedCategory === 'skills' && selectedAgent !== 'opencode' && (
<ProviderSkills
selectedProvider={selectedAgent}
currentProjects={projects.map<SkillsProject>((project) => ({
projectId: project.name,
displayName: project.displayName,
fullPath: project.fullPath,
path: project.path,
}))}
/>
)}
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../../../lib/utils';
import type { AgentCategory } from '../../../../types/types';
import type { AgentCategoryTabsSectionProps } from '../types';
const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export default function AgentCategoryTabsSection({
categories,
selectedAgent,
selectedCategory,
onSelectCategory,
}: AgentCategoryTabsSectionProps) {
@@ -14,7 +14,7 @@ export default function AgentCategoryTabsSection({
return (
<div className="flex-shrink-0 border-b border-border">
<div role="tablist" className="flex overflow-x-auto px-2 md:px-4">
{AGENT_CATEGORIES.map((category) => (
{categories.map((category) => (
<button
key={category}
role="tab"
@@ -30,6 +30,9 @@ export default function AgentCategoryTabsSection({
{category === 'account' && t('tabs.account')}
{category === 'permissions' && t('tabs.permissions')}
{category === 'mcp' && t('tabs.mcpServers')}
{category === 'skills' && t('tabs.skills', {
defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills',
})}
</button>
))}
</div>

View File

@@ -32,6 +32,8 @@ export type AgentsSettingsTabProps = {
};
export type AgentCategoryTabsSectionProps = {
categories: AgentCategory[];
selectedAgent: AgentProvider;
selectedCategory: AgentCategory;
onSelectCategory: (category: AgentCategory) => void;
};

View File

@@ -43,7 +43,7 @@ function Sidebar({
}: SidebarProps) {
const { t } = useTranslation(['sidebar', 'common']);
const { isPWA } = useDeviceSettings({ trackMobile: false });
const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
const { updateAvailable, restartRequired, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
'siteboon',
'claudecodeui',
);
@@ -224,6 +224,7 @@ function Sidebar({
onExpand={handleExpandSidebar}
onShowSettings={onShowSettings}
updateAvailable={updateAvailable}
restartRequired={restartRequired}
onShowVersionModal={() => setShowVersionModal(true)}
t={t}
/>
@@ -296,6 +297,7 @@ function Sidebar({
onCreateProject={() => setShowNewProject(true)}
onCollapseSidebar={handleCollapseSidebar}
updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react';
import { Settings, Sparkles, PanelLeftOpen, Bug, AlertTriangle } from 'lucide-react';
import type { TFunction } from 'i18next';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
@@ -16,6 +16,7 @@ type SidebarCollapsedProps = {
onExpand: () => void;
onShowSettings: () => void;
updateAvailable: boolean;
restartRequired: boolean;
onShowVersionModal: () => void;
t: TFunction;
};
@@ -24,6 +25,7 @@ export default function SidebarCollapsed({
onExpand,
onShowSettings,
updateAvailable,
restartRequired,
onShowVersionModal,
t,
}: SidebarCollapsedProps) {
@@ -75,6 +77,18 @@ export default function SidebarCollapsed({
<DiscordIcon className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
{/* Restart-required indicator */}
{restartRequired && (
<div
className="relative flex h-8 w-8 items-center justify-center rounded-lg"
aria-label={t('version.restartRequired')}
title={t('version.restartRequired')}
>
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" />
</div>
)}
{/* Update indicator */}
{updateAvailable && (
<button

View File

@@ -141,6 +141,7 @@ type SidebarContentProps = {
onCreateProject: () => void;
onCollapseSidebar: () => void;
updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
@@ -178,6 +179,7 @@ export default function SidebarContent({
onCreateProject,
onCollapseSidebar,
updateAvailable,
restartRequired,
releaseInfo,
latestVersion,
currentVersion,
@@ -553,6 +555,7 @@ export default function SidebarContent({
<SidebarFooter
updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo}
latestVersion={latestVersion}
currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, ArrowUpCircle, Bug } from 'lucide-react';
import { Settings, ArrowUpCircle, Bug, AlertTriangle } from 'lucide-react';
import type { TFunction } from 'i18next';
import { IS_PLATFORM } from '../../../../constants/config';
import type { ReleaseInfo } from '../../../../types/sharedTypes';
@@ -18,6 +18,7 @@ function DiscordIcon({ className }: { className?: string }) {
type SidebarFooterProps = {
updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null;
latestVersion: string | null;
currentVersion: string;
@@ -28,6 +29,7 @@ type SidebarFooterProps = {
export default function SidebarFooter({
updateAvailable,
restartRequired,
releaseInfo,
latestVersion,
currentVersion,
@@ -37,6 +39,22 @@ export default function SidebarFooter({
}: SidebarFooterProps) {
return (
<div className="flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0)' }}>
{/* Restart-required banner: the running server version differs from the
installed/frontend version (updated but not restarted). */}
{restartRequired && (
<>
<div className="nav-divider" />
<div className="px-2 py-1.5 md:px-2 md:py-1.5">
<div className="flex items-center gap-2.5 rounded-lg border border-amber-300/60 bg-amber-50/80 px-2.5 py-2 dark:border-amber-700/40 dark:bg-amber-900/15">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-500 dark:text-amber-400" />
<span className="min-w-0 flex-1 text-xs font-medium text-amber-700 dark:text-amber-300">
{t('version.restartRequired')}
</span>
</div>
</div>
</>
)}
{/* Update banner */}
{updateAvailable && (
<>

View File

@@ -0,0 +1,348 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type {
ApiResponse,
ProviderSkill,
ProviderSkillCreatePayload,
ProviderSkillsResponse,
SkillsProject,
SkillsProvider,
SkillsScope,
} from '../types';
type SkillsCacheEntry = {
skills: ProviderSkill[];
updatedAt: number;
};
type ProjectTarget = {
projectId: string;
displayName: string;
path: string;
};
const SKILLS_CACHE_TTL_MS = 5 * 60_000;
const skillsCache = new Map<string, SkillsCacheEntry>();
const SKILL_SCOPE_ORDER: Record<SkillsScope, number> = {
user: 0,
plugin: 1,
repo: 2,
project: 3,
admin: 4,
system: 5,
};
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const getApiErrorMessage = (payload: unknown, fallback: string): string => {
if (!payload || typeof payload !== 'object') {
return fallback;
}
const record = payload as Record<string, unknown>;
const error = record.error;
if (error && typeof error === 'object') {
const message = (error as Record<string, unknown>).message;
if (typeof message === 'string' && message.trim()) {
return message;
}
}
if (typeof error === 'string' && error.trim()) {
return error;
}
const details = record.details;
if (typeof details === 'string' && details.trim()) {
return details;
}
return fallback;
};
const isSkillsScope = (value: unknown): value is SkillsScope => (
value === 'user'
|| value === 'project'
|| value === 'plugin'
|| value === 'repo'
|| value === 'admin'
|| value === 'system'
);
const normalizeScope = (value: unknown): SkillsScope => (
isSkillsScope(value) ? value : 'user'
);
const createProjectTargets = (projects: SkillsProject[]): ProjectTarget[] => {
const seenPaths = new Set<string>();
return projects.reduce<ProjectTarget[]>((acc, project) => {
const projectPath = project.fullPath || project.path || '';
if (!projectPath || seenPaths.has(projectPath)) {
return acc;
}
seenPaths.add(projectPath);
acc.push({
projectId: project.projectId,
displayName: project.displayName || project.projectId,
path: projectPath,
});
return acc;
}, []);
};
const normalizeSkill = (
provider: SkillsProvider,
skill: Partial<ProviderSkill>,
project?: ProjectTarget,
): ProviderSkill => {
const scope = normalizeScope(skill.scope);
const shouldAttachProject = scope === 'project' || scope === 'repo';
return {
provider,
name: String(skill.name ?? ''),
description: String(skill.description ?? ''),
command: String(skill.command ?? ''),
scope,
sourcePath: String(skill.sourcePath ?? ''),
pluginName: typeof skill.pluginName === 'string' ? skill.pluginName : undefined,
pluginId: typeof skill.pluginId === 'string' ? skill.pluginId : undefined,
projectDisplayName: shouldAttachProject
? project?.displayName ?? skill.projectDisplayName
: skill.projectDisplayName,
projectPath: shouldAttachProject
? project?.path ?? skill.projectPath
: skill.projectPath,
};
};
const getSkillIdentity = (skill: ProviderSkill): string => (
[
skill.provider,
skill.scope,
skill.command,
skill.sourcePath || 'no-source-path',
skill.projectPath || 'global',
].join(':')
);
const sortSkills = (skills: ProviderSkill[]): ProviderSkill[] => (
[...skills].sort((left, right) => {
const scopeDelta = SKILL_SCOPE_ORDER[left.scope] - SKILL_SCOPE_ORDER[right.scope];
if (scopeDelta !== 0) {
return scopeDelta;
}
const projectDelta = (left.projectDisplayName || '').localeCompare(right.projectDisplayName || '');
if (projectDelta !== 0) {
return projectDelta;
}
return left.command.localeCompare(right.command);
})
);
const mergeSkills = (
existingSkills: ProviderSkill[],
incomingSkills: ProviderSkill[],
): ProviderSkill[] => {
const skillsById = new Map<string, ProviderSkill>();
existingSkills.forEach((skill) => {
skillsById.set(getSkillIdentity(skill), skill);
});
incomingSkills.forEach((skill) => {
skillsById.set(getSkillIdentity(skill), skill);
});
return sortSkills([...skillsById.values()]);
};
const fetchProviderSkills = async (
provider: SkillsProvider,
project?: ProjectTarget,
): Promise<ProviderSkill[]> => {
const params = new URLSearchParams();
if (project?.path) {
params.set('workspacePath', project.path);
}
const response = await authenticatedFetch(
`/api/providers/${provider}/skills${params.toString() ? `?${params.toString()}` : ''}`,
);
const data = await toResponseJson<ApiResponse<ProviderSkillsResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, `Failed to load ${provider} skills`));
}
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill, project));
};
const saveProviderSkills = async (
provider: SkillsProvider,
payload: ProviderSkillCreatePayload,
): Promise<ProviderSkill[]> => {
const response = await authenticatedFetch(`/api/providers/${provider}/skills`, {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await toResponseJson<ApiResponse<ProviderSkillsResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to save skills'));
}
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill));
};
const getCacheKey = (provider: SkillsProvider, projects: ProjectTarget[]): string => {
const projectKey = projects.map((project) => project.path).sort().join('|');
return `${provider}:${projectKey}`;
};
const clearProviderSkillCache = (provider: SkillsProvider): void => {
for (const cacheKey of [...skillsCache.keys()]) {
if (cacheKey.startsWith(`${provider}:`)) {
skillsCache.delete(cacheKey);
}
}
};
type UseProviderSkillsArgs = {
selectedProvider: SkillsProvider;
currentProjects: SkillsProject[];
};
export function useProviderSkills({ selectedProvider, currentProjects }: UseProviderSkillsArgs) {
const [skills, setSkills] = useState<ProviderSkill[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const activeLoadIdRef = useRef(0);
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
const cacheKey = useMemo(() => getCacheKey(selectedProvider, projectTargets), [projectTargets, selectedProvider]);
const refreshSkills = useCallback(async (options: { force?: boolean } = {}) => {
const loadId = activeLoadIdRef.current + 1;
activeLoadIdRef.current = loadId;
const cachedEntry = skillsCache.get(cacheKey);
const canUseCache = !options.force && cachedEntry && Date.now() - cachedEntry.updatedAt < SKILLS_CACHE_TTL_MS;
if (canUseCache) {
setSkills(cachedEntry.skills);
setIsLoading(false);
setIsLoadingProjectScopes(false);
setLoadError(null);
return;
}
if (cachedEntry && !options.force) {
setSkills(cachedEntry.skills);
} else {
setSkills([]);
}
setIsLoading(!cachedEntry);
setIsLoadingProjectScopes(false);
setLoadError(null);
let nextSkills = cachedEntry && !options.force ? cachedEntry.skills : [];
let firstError: string | null = null;
try {
const globalSkills = await fetchProviderSkills(selectedProvider);
if (activeLoadIdRef.current !== loadId) {
return;
}
nextSkills = mergeSkills(nextSkills, globalSkills);
setSkills(nextSkills);
} catch (error) {
firstError = error instanceof Error ? error.message : 'Failed to load skills';
}
if (activeLoadIdRef.current !== loadId) {
return;
}
setIsLoading(false);
if (projectTargets.length === 0) {
const finalSkills = sortSkills(nextSkills);
skillsCache.set(cacheKey, { skills: finalSkills, updatedAt: Date.now() });
setSkills(finalSkills);
setLoadError(firstError);
return;
}
setIsLoadingProjectScopes(true);
await Promise.all(projectTargets.map(async (project) => {
try {
const projectSkills = await fetchProviderSkills(selectedProvider, project);
if (activeLoadIdRef.current !== loadId) {
return;
}
nextSkills = mergeSkills(nextSkills, projectSkills);
setSkills(nextSkills);
} catch (error) {
firstError = firstError || (error instanceof Error ? error.message : 'Failed to load skills');
}
}));
if (activeLoadIdRef.current !== loadId) {
return;
}
const finalSkills = sortSkills(nextSkills);
skillsCache.set(cacheKey, { skills: finalSkills, updatedAt: Date.now() });
setSkills(finalSkills);
setLoadError(firstError);
setIsLoadingProjectScopes(false);
}, [cacheKey, projectTargets, selectedProvider]);
const addSkills = useCallback(async (payload: ProviderSkillCreatePayload) => {
try {
const createdSkills = await saveProviderSkills(selectedProvider, payload);
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
setSaveStatus('success');
return createdSkills;
} catch (error) {
setSaveStatus('error');
throw error;
}
}, [refreshSkills, selectedProvider]);
useEffect(() => {
void refreshSkills();
}, [refreshSkills]);
useEffect(() => {
setSaveStatus(null);
}, [selectedProvider]);
useEffect(() => {
if (saveStatus === null) {
return;
}
const timer = window.setTimeout(() => setSaveStatus(null), 6000);
return () => window.clearTimeout(timer);
}, [saveStatus]);
return {
skills,
isLoading,
isLoadingProjectScopes,
loadError,
saveStatus,
addSkills,
refreshSkills,
};
}

View File

@@ -0,0 +1 @@
export { default as ProviderSkills } from './view/ProviderSkills';

View File

@@ -0,0 +1,60 @@
import type { LLMProvider } from '../../types/app';
export type SkillsProvider = LLMProvider;
export type SkillsScope = 'user' | 'project' | 'plugin' | 'repo' | 'admin' | 'system';
export type SkillsProject = {
projectId: string;
displayName?: string;
fullPath?: string;
path?: string;
};
export type ProviderSkill = {
provider: SkillsProvider;
name: string;
description: string;
command: string;
scope: SkillsScope;
sourcePath: string;
pluginName?: string;
pluginId?: string;
projectDisplayName?: string;
projectPath?: string;
};
export type ProviderSkillCreateEntryPayload = {
content: string;
directoryName?: string;
fileName?: string;
files?: Array<{
relativePath: string;
content: string;
encoding: 'base64';
}>;
};
export type ProviderSkillCreatePayload = {
entries: ProviderSkillCreateEntryPayload[];
};
export type ProviderSkillsResponse = {
provider: SkillsProvider;
skills: Array<Partial<ProviderSkill>>;
};
export type ApiSuccessResponse<T> = {
success: true;
data: T;
};
export type ApiErrorResponse = {
success: false;
error?: {
code?: string;
message?: string;
details?: unknown;
};
};
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

View File

@@ -0,0 +1,654 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import {
CheckCircle2,
FileCode2,
FileText,
FileUp,
FolderUp,
Loader2,
RefreshCw,
Search,
Upload,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
} from '../../../shared/view/ui';
import { useProviderSkills } from '../hooks/useProviderSkills';
import type {
ProviderSkill,
ProviderSkillCreateEntryPayload,
SkillsProject,
SkillsProvider,
SkillsScope,
} from '../types';
type ProviderSkillsProps = {
selectedProvider: SkillsProvider;
currentProjects: SkillsProject[];
};
type QueuedSkillSourceFile = {
file: File;
relativePath: string;
};
type QueuedSkillFile = {
id: string;
name: string;
size: number;
kind: 'markdown' | 'folder';
skillFile: File;
files: QueuedSkillSourceFile[];
};
const MAX_SKILL_FOLDER_FILES = 500;
const MAX_SKILL_FOLDER_BYTES = 30 * 1024 * 1024;
const PROVIDER_NAMES: Record<SkillsProvider, string> = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
gemini: 'Gemini',
opencode: 'OpenCode',
};
const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string> = {
claude: '~/.claude/skills/<skill-name>/SKILL.md',
codex: '~/.agents/skills/<skill-name>/SKILL.md',
cursor: '~/.cursor/skills/<skill-name>/SKILL.md',
gemini: '~/.gemini/skills/<skill-name>/SKILL.md',
};
const SCOPE_LABELS: Record<SkillsScope, string> = {
user: 'User',
plugin: 'Plugin',
repo: 'Repo',
project: 'Project',
admin: 'Admin',
system: 'System',
};
const SCOPE_BADGE_CLASSES: Record<SkillsScope, string> = {
user: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
plugin: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300',
repo: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
project: 'border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-300',
admin: 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300',
system: 'border-slate-500/30 bg-slate-500/10 text-slate-700 dark:text-slate-300',
};
const SCOPE_ORDER: SkillsScope[] = ['user', 'plugin', 'repo', 'project', 'admin', 'system'];
const groupSkillsByScope = (skills: ProviderSkill[]): Array<{ scope: SkillsScope; skills: ProviderSkill[] }> => (
SCOPE_ORDER
.map((scope) => ({ scope, skills: skills.filter((skill) => skill.scope === scope) }))
.filter((group) => group.skills.length > 0)
);
const formatFileSize = (size: number): string => {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const getBrowserRelativePath = (file: File): string => {
const fileWithRelativePath = file as File & {
path?: string;
webkitRelativePath?: string;
};
return (
fileWithRelativePath.webkitRelativePath
|| fileWithRelativePath.path
|| file.name
)
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/^\/+/, '');
};
const getParentPath = (filePath: string): string => {
const separatorIndex = filePath.lastIndexOf('/');
return separatorIndex >= 0 ? filePath.slice(0, separatorIndex) : '';
};
const getBaseName = (filePath: string): string => {
const segments = filePath.split('/').filter(Boolean);
return segments.at(-1) || 'skill';
};
const readFileAsBase64 = (file: File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const separatorIndex = result.indexOf(',');
resolve(separatorIndex >= 0 ? result.slice(separatorIndex + 1) : result);
};
reader.onerror = () => reject(reader.error ?? new Error(`Failed to read ${file.name}`));
reader.readAsDataURL(file);
});
const buildQueuedSkillFolders = (selectedFiles: File[]): QueuedSkillFile[] => {
if (selectedFiles.length > MAX_SKILL_FOLDER_FILES) {
throw new Error(`A skill folder can contain up to ${MAX_SKILL_FOLDER_FILES} files.`);
}
const totalSize = selectedFiles.reduce((size, file) => size + file.size, 0);
if (totalSize > MAX_SKILL_FOLDER_BYTES) {
throw new Error('Selected skill folders must be smaller than 30 MB in total.');
}
const files = selectedFiles.map((file) => ({
file,
relativePath: getBrowserRelativePath(file),
}));
const skillRoots = files
.filter(({ relativePath }) => getBaseName(relativePath).toLowerCase() === 'skill.md')
.map(({ relativePath }) => getParentPath(relativePath))
.sort((left, right) => right.length - left.length);
if (skillRoots.length === 0) {
throw new Error('The selected folder does not contain a SKILL.md file.');
}
return skillRoots.map((skillRoot) => {
const skillFiles = files.filter(({ relativePath }) => {
const owningRoot = skillRoots.find((candidateRoot) => {
const normalizedRelativePath = relativePath.toLowerCase();
const normalizedSkillPath = `${candidateRoot}/skill.md`.toLowerCase();
return normalizedRelativePath === normalizedSkillPath
|| relativePath.startsWith(`${candidateRoot}/`);
});
return owningRoot === skillRoot;
});
const skillSourceFile = skillFiles.find(
({ relativePath }) => (
relativePath.toLowerCase() === `${skillRoot}/skill.md`.toLowerCase()
),
);
if (!skillSourceFile) {
throw new Error(`Could not read SKILL.md from ${getBaseName(skillRoot)}.`);
}
return {
id: `folder:${skillRoot}:${skillFiles.map(({ file }) => file.lastModified).join(':')}`,
name: getBaseName(skillRoot),
size: skillFiles.reduce((size, { file }) => size + file.size, 0),
kind: 'folder' as const,
skillFile: skillSourceFile.file,
files: skillFiles.map(({ file, relativePath }) => ({
file,
relativePath: skillRoot ? relativePath.slice(skillRoot.length + 1) : relativePath,
})),
};
});
};
export default function ProviderSkills({ selectedProvider, currentProjects }: ProviderSkillsProps) {
const { t } = useTranslation('settings');
const {
skills,
isLoading,
isLoadingProjectScopes,
loadError,
saveStatus,
addSkills,
refreshSkills,
} = useProviderSkills({ selectedProvider, currentProjects });
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const providerName = PROVIDER_NAMES[selectedProvider];
const providerPath = selectedProvider === 'opencode' ? null : PROVIDER_SKILL_PATHS[selectedProvider];
useEffect(() => {
setQueuedFiles([]);
setSubmitError(null);
setIsSubmitting(false);
setSearchQuery('');
}, [selectedProvider]);
useEffect(() => {
folderInputRef.current?.setAttribute('webkitdirectory', '');
folderInputRef.current?.setAttribute('directory', '');
}, []);
const filteredSkills = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLocaleLowerCase();
if (!normalizedQuery) {
return skills;
}
return skills.filter((skill) => (
[
skill.command,
skill.name,
skill.description,
skill.scope,
skill.pluginName,
skill.projectDisplayName,
skill.sourcePath,
]
.filter(Boolean)
.some((value) => value?.toLocaleLowerCase().includes(normalizedQuery))
));
}, [searchQuery, skills]);
const groupedSkills = useMemo(() => groupSkillsByScope(filteredSkills), [filteredSkills]);
const queueSkillFolders = useCallback((selectedFiles: File[]) => {
const queuedFolders = buildQueuedSkillFolders(selectedFiles);
setQueuedFiles((previous) => {
const nextMap = new Map(previous.map((file) => [file.id, file]));
queuedFolders.forEach((folder) => nextMap.set(folder.id, folder));
return [...nextMap.values()].slice(0, 20);
});
}, []);
const handleDrop = useCallback((files: File[]) => {
const includesDirectory = files.some((file) => getBrowserRelativePath(file).includes('/'));
if (includesDirectory) {
try {
queueSkillFolders(files);
setSubmitError(null);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder');
}
return;
}
const acceptedFiles = files
.filter((file) => file.name.toLowerCase().endsWith('.md'))
.slice(0, 20);
if (acceptedFiles.length === 0) {
setSubmitError('Drop one or more markdown files or a folder containing SKILL.md.');
return;
}
setQueuedFiles((previous) => {
const nextMap = new Map(previous.map((file) => [file.id, file]));
acceptedFiles.forEach((file) => {
const id = `${file.name}:${file.size}:${file.lastModified}`;
nextMap.set(id, {
id,
name: file.name,
size: file.size,
kind: 'markdown',
skillFile: file,
files: [{ file, relativePath: 'SKILL.md' }],
});
});
return [...nextMap.values()].slice(0, 20);
});
setSubmitError(null);
}, [queueSkillFolders]);
const handleFolderSelection = useCallback((selectedFiles: File[]) => {
if (selectedFiles.length === 0) {
return;
}
try {
queueSkillFolders(selectedFiles);
setSubmitError(null);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder');
}
}, [queueSkillFolders]);
const { getRootProps, isDragActive } = useDropzone({
maxFiles: MAX_SKILL_FOLDER_FILES,
noClick: true,
noKeyboard: true,
onDrop: handleDrop,
});
const handleUploadInstall = useCallback(async () => {
if (queuedFiles.length === 0) {
setSubmitError('Add one or more markdown files first.');
return;
}
setIsSubmitting(true);
setSubmitError(null);
try {
const entries = await Promise.all<ProviderSkillCreateEntryPayload>(queuedFiles.map(async (queuedFile) => ({
fileName: queuedFile.kind === 'folder' ? `${queuedFile.name}.md` : queuedFile.name,
directoryName: queuedFile.kind === 'folder' ? queuedFile.name : undefined,
content: await queuedFile.skillFile.text(),
files: queuedFile.kind === 'folder'
? await Promise.all(
queuedFile.files
.filter(({ relativePath }) => relativePath.toLowerCase() !== 'skill.md')
.map(async ({ file, relativePath }) => ({
relativePath,
content: await readFileAsBase64(file),
encoding: 'base64' as const,
})),
)
: undefined,
})));
await addSkills({ entries });
setQueuedFiles([]);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
} finally {
setIsSubmitting(false);
}
}, [addSkills, queuedFiles]);
return (
<div className="min-w-0 space-y-4 overflow-x-hidden">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
<FileCode2 className="h-4 w-4" strokeWidth={1.7} />
</div>
<div className="min-w-0 space-y-1">
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
<p className="text-sm text-muted-foreground">
Install global {providerName} skills from `.md` files or complete skill folders.
</p>
</div>
</div>
<Button
onClick={() => void refreshSkills({ force: true })}
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={isLoading || isLoadingProjectScopes}
>
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
Refresh
</Button>
</div>
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="text-sm font-medium text-foreground">Upload Skills</div>
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
isDragActive
? 'border-foreground/40 bg-muted/35'
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
)}
>
<input
ref={fileInputRef}
type="file"
accept=".md,text/markdown"
multiple
className="hidden"
onChange={(event) => {
handleDrop(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
handleFolderSelection(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
<div className="text-sm text-muted-foreground">
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FileUp className="h-4 w-4" />
Choose Files
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => folderInputRef.current?.click()}
className="w-full sm:w-auto"
>
<FolderUp className="h-4 w-4" />
Choose Folder
</Button>
</div>
</div>
</div>
{queuedFiles.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Queued Files</div>
<div className="grid gap-2">
{queuedFiles.map((queuedFile) => (
<div
key={queuedFile.id}
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
<div className="text-xs text-muted-foreground">
{queuedFile.kind === 'folder'
? `${queuedFile.files.length} files`
: 'Markdown file'}
{' · '}
{formatFileSize(queuedFile.size)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full sm:w-auto"
onClick={() => {
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
}}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Button
type="button"
onClick={() => void handleUploadInstall()}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
</Button>
<span className="text-xs text-muted-foreground">
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
</span>
</div>
</div>
{(submitError || loadError) && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{submitError || loadError}
</div>
)}
{saveStatus === 'success' && (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-4 w-4" />
Skills saved successfully.
</div>
)}
</CardContent>
</Card>
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
<CardHeader className="border-b border-border/60">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<CardTitle>Visible Skills</CardTitle>
<CardDescription>
The list below comes from the provider skill discovery API and includes global and project-aware locations.
</CardDescription>
</div>
<div className="relative w-full lg:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search skills..."
aria-label="Search visible skills"
className="h-9 w-full pl-9 pr-9"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
aria-label="Clear skill search"
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{isLoadingProjectScopes && (
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Scanning project skills
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-5 p-4">
{isLoading && skills.length === 0 && (
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
Loading {providerName} skills
</div>
)}
{!isLoading && skills.length === 0 && (
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-muted-foreground">
<FileText className="h-6 w-6" />
</div>
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
<div className="mt-1 text-sm text-muted-foreground">
Add a global skill above or create project-specific skill folders in your workspace.
</div>
</div>
)}
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
<div className="mt-1 text-sm text-muted-foreground">
Try a different command, name, scope, project, or source path.
</div>
</div>
)}
{groupedSkills.map((group) => (
<section key={group.scope} className="min-w-0 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className={cn('rounded-full px-2.5 py-1 text-xs', SCOPE_BADGE_CLASSES[group.scope])}>
{SCOPE_LABELS[group.scope]}
</Badge>
<span className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
</span>
</div>
<div className="grid min-w-0 gap-3 lg:grid-cols-2">
{group.skills.map((skill) => (
<div
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
className="min-w-0 rounded-3xl border border-border/70 bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm"
>
<div className="min-w-0 space-y-1">
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
<div className="text-sm text-muted-foreground">{skill.name}</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{skill.description || 'No description provided in the skill front matter.'}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
{skill.pluginName && (
<Badge variant="outline" className="rounded-full bg-background/70">
Plugin: {skill.pluginName}
</Badge>
)}
{skill.projectDisplayName && (
<Badge variant="outline" className="rounded-full bg-background/70">
Project: {skill.projectDisplayName}
</Badge>
)}
</div>
<div className="mt-4 min-w-0 rounded-2xl border border-border/60 bg-muted/20 px-3 py-2">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
</div>
</div>
))}
</div>
</section>
))}
</CardContent>
</Card>
</div>
);
}

View File

@@ -7,6 +7,7 @@ type UiPreferences = {
autoScrollToBottom: boolean;
sendByCtrlEnter: boolean;
sidebarVisible: boolean;
voiceEnabled: boolean;
};
type UiPreferenceKey = keyof UiPreferences;
@@ -39,6 +40,7 @@ const DEFAULTS: UiPreferences = {
autoScrollToBottom: true,
sendByCtrlEnter: false,
sidebarVisible: true,
voiceEnabled: false,
};
const PREFERENCE_KEYS = Object.keys(DEFAULTS) as UiPreferenceKey[];

View File

@@ -28,20 +28,31 @@ export const useVersionCheck = (owner: string, repo: string) => {
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);
const [installMode, setInstallMode] = useState<InstallMode>('git');
const [runningVersion, setRunningVersion] = useState<string | null>(null);
const [restartRequired, setRestartRequired] = useState(false);
useEffect(() => {
const fetchInstallMode = async () => {
const fetchHealth = async () => {
try {
const response = await fetch('/health');
const data = await response.json();
if (data.installMode === 'npm' || data.installMode === 'git') {
setInstallMode(data.installMode);
}
// `data.version` is the version the server process is actually running.
// This module's `version` is baked into the frontend bundle at build
// time, so it reflects the installed (on-disk) package. If they differ,
// the package was updated but the server process was not restarted, and
// DB-backed actions may silently fail until it is.
if (typeof data.version === 'string' && data.version.length > 0) {
setRunningVersion(data.version);
setRestartRequired(data.version !== version);
}
} catch {
// Default to git on error
// Default to git / no restart hint on error
}
};
fetchInstallMode();
fetchHealth();
}, []);
useEffect(() => {
@@ -84,5 +95,5 @@ export const useVersionCheck = (owner: string, repo: string) => {
return () => clearInterval(interval);
}, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode };
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode, runningVersion, restartRequired };
};

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
export type VoiceConfig = {
baseUrl: string;
apiKey: string;
sttModel: string;
ttsModel: string;
ttsVoice: string;
ttsFormat: string;
};
const STORAGE_KEY = 'voiceConfig';
export const VOICE_CONFIG_SYNC_EVENT = 'voice-config:sync';
const DEFAULTS: VoiceConfig = { baseUrl: '', apiKey: '', sttModel: '', ttsModel: '', ttsVoice: '', ttsFormat: '' };
export function readVoiceConfig(): VoiceConfig {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULTS };
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ...DEFAULTS };
const config = { ...DEFAULTS };
for (const key of Object.keys(DEFAULTS) as (keyof VoiceConfig)[]) {
if (typeof parsed[key] === 'string') config[key] = parsed[key];
}
return config;
} catch {
return { ...DEFAULTS };
}
}
// Headers the voice proxy reads to target a per-user OpenAI-compatible backend.
// Empty fields are omitted so the server's env defaults apply.
export function voiceConfigHeaders(): Record<string, string> {
if (typeof window === 'undefined') return {};
const c = readVoiceConfig();
const h: Record<string, string> = {};
if (c.apiKey) h['x-voice-api-key'] = c.apiKey;
if (c.sttModel) h['x-voice-stt-model'] = c.sttModel;
if (c.ttsModel) h['x-voice-tts-model'] = c.ttsModel;
if (c.ttsVoice) h['x-voice-tts-voice'] = c.ttsVoice;
if (c.ttsFormat.trim()) h['x-voice-tts-format'] = c.ttsFormat.trim();
return h;
}
export function useVoiceConfig() {
const [config, setConfig] = useState<VoiceConfig>(() =>
typeof window === 'undefined' ? { ...DEFAULTS } : readVoiceConfig(),
);
const update = (patch: Partial<VoiceConfig>) => {
setConfig((prev) => {
const next = { ...prev, ...patch };
try {
const stored: Partial<VoiceConfig> = { ...next };
if (next.ttsFormat.trim()) stored.ttsFormat = next.ttsFormat.trim();
else delete stored.ttsFormat;
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
window.dispatchEvent(new Event(VOICE_CONFIG_SYNC_EVENT));
} catch {
/* ignore persistence errors */
}
return next;
});
};
return { config, update };
}

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen."
},
"version": {
"updateAvailable": "Update verfügbar"
"updateAvailable": "Update verfügbar",
"restartRequired": "Update installiert zum Anwenden Server neu starten"
},
"search": {
"modeProjects": "Projekte",

View File

@@ -122,6 +122,14 @@
}
}
},
"voice": {
"input": "Voice input",
"stopRecording": "Stop recording",
"transcribing": "Transcribing…",
"speak": "Read aloud",
"stopSpeaking": "Stop",
"loading": "Loading…"
},
"input": {
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
"placeholderDefault": "Type your message...",

View File

@@ -4,6 +4,7 @@
"account": "Account",
"permissions": "Permissions",
"mcpServers": "MCP Servers",
"skills": "Skills",
"appearance": "Appearance"
},
"account": {
@@ -49,6 +50,21 @@
"resetToDefaults": "Reset to Defaults",
"cancelChanges": "Cancel Changes"
},
"voiceSettings": {
"title": "Voice",
"description": "Speech-to-text input and read-aloud, via an OpenAI-compatible audio backend.",
"enable": "Enable voice",
"enableDescription": "Show the mic button and the read-aloud button on messages.",
"backendTitle": "Backend",
"backendDescription": "Point at OpenAI, Groq, or a local server (LocalAI, Speaches, Kokoro-FastAPI). Leave blank to use the server default.",
"baseUrl": "Base URL",
"apiKey": "API key",
"sttModel": "Speech-to-text model",
"ttsModel": "Text-to-speech model",
"voice": "Voice",
"format": "Audio format",
"note": "A custom base URL is called directly by your browser and must allow browser CORS requests. Leave it blank to use the server-configured backend."
},
"quickSettings": {
"title": "Quick Settings",
"sections": {
@@ -63,6 +79,7 @@
"showThinking": "Show thinking",
"autoScrollToBottom": "Auto-scroll to bottom",
"sendByCtrlEnter": "Send by Ctrl+Enter",
"voiceEnabled": "Voice (mic + read aloud)",
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
"dragHandle": {
"dragging": "Dragging handle",
@@ -93,6 +110,7 @@
"appearance": "Appearance",
"git": "Git",
"apiTokens": "API & Tokens",
"voice": "Voice",
"tasks": "Tasks",
"browser": "Browser",
"notifications": "Notifications",
@@ -113,7 +131,7 @@
},
"sound": {
"title": "Sound",
"description": "Play a short tone when a chat run finishes.",
"description": "Play a short tone when a chat run finishes or needs tool approval.",
"enabled": "Enabled",
"test": "Test sound"
},

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Error restoring session. Please try again."
},
"version": {
"updateAvailable": "Update available"
"updateAvailable": "Update available",
"restartRequired": "Update installed — restart the server to apply"
},
"search": {
"modeProjects": "Projects",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer."
},
"version": {
"updateAvailable": "Mise à jour disponible"
"updateAvailable": "Mise à jour disponible",
"restartRequired": "Mise à jour installée — redémarrez le serveur pour l'appliquer"
},
"search": {
"modeProjects": "Projets",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Errore durante il ripristino della sessione. Riprova."
},
"version": {
"updateAvailable": "Aggiornamento disponibile"
"updateAvailable": "Aggiornamento disponibile",
"restartRequired": "Aggiornamento installato — riavvia il server per applicarlo"
},
"search": {
"modeProjects": "Progetti",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。"
},
"version": {
"updateAvailable": "アップデートあり"
"updateAvailable": "アップデートあり",
"restartRequired": "更新が適用されていません。サーバーを再起動してください"
},
"deleteConfirmation": {
"deleteProject": "プロジェクトを除去",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "세션 복원 오류. 다시 시도해주세요."
},
"version": {
"updateAvailable": "업데이트 가능"
"updateAvailable": "업데이트 가능",
"restartRequired": "업데이트가 설치됨 — 적용하려면 서버를 재시작하세요"
},
"deleteConfirmation": {
"deleteProject": "프로젝트 제거",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова."
},
"version": {
"updateAvailable": "Доступно обновление"
"updateAvailable": "Доступно обновление",
"restartRequired": "Обновление установлено — перезапустите сервер для применения"
},
"search": {
"modeProjects": "Проекты",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene."
},
"version": {
"updateAvailable": "Güncelleme mevcut"
"updateAvailable": "Güncelleme mevcut",
"restartRequired": "Güncelleme yüklendi — uygulamak için sunucuyu yeniden başlatın"
},
"search": {
"modeProjects": "Projeler",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "恢复会话时出错,请重试。"
},
"version": {
"updateAvailable": "有可用更新"
"updateAvailable": "有可用更新",
"restartRequired": "已安装更新 — 请重启服务器以生效"
},
"search": {
"modeProjects": "项目",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "還原工作階段時出錯,請重試。"
},
"version": {
"updateAvailable": "有可用更新"
"updateAvailable": "有可用更新",
"restartRequired": "已安裝更新 — 請重新啟動伺服器以套用"
},
"search": {
"modeProjects": "專案",

60
src/lib/voiceApi.ts Normal file
View File

@@ -0,0 +1,60 @@
import { authenticatedFetch } from '../utils/api';
import { readVoiceConfig, voiceConfigHeaders } from '../hooks/useVoiceConfig';
function directUrl(baseUrl: string, path: string): string {
return `${baseUrl.replace(/\/$/, '')}${path}`;
}
export function voiceConfigSignature(): string {
return JSON.stringify(readVoiceConfig());
}
export function transcribeVoice(blob: Blob, filename: string): Promise<Response> {
const config = readVoiceConfig();
const body = new FormData();
if (config.baseUrl.trim()) {
body.append('file', blob, filename);
body.append('model', config.sttModel || 'whisper-1');
return fetch(directUrl(config.baseUrl.trim(), '/audio/transcriptions'), {
method: 'POST',
headers: config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {},
body,
});
}
body.append('audio', blob, filename);
return authenticatedFetch('/api/voice/transcribe', {
method: 'POST',
headers: voiceConfigHeaders(),
body,
});
}
export function synthesizeVoice(text: string, signal: AbortSignal): Promise<Response> {
const config = readVoiceConfig();
if (config.baseUrl.trim()) {
return fetch(directUrl(config.baseUrl.trim(), '/audio/speech'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
},
body: JSON.stringify({
model: config.ttsModel || 'tts-1',
voice: config.ttsVoice || 'alloy',
input: text,
...(config.ttsFormat.trim() ? { response_format: config.ttsFormat.trim() } : {}),
}),
signal,
});
}
return authenticatedFetch('/api/voice/tts', {
method: 'POST',
body: JSON.stringify({ text }),
headers: voiceConfigHeaders(),
signal,
});
}

196
src/lib/voicePlayer.ts Normal file
View File

@@ -0,0 +1,196 @@
import { synthesizeVoice, voiceConfigSignature } from './voiceApi';
// A single app-level audio player for read-aloud. It owns one <audio> element, lives
// outside the React tree, and caches generated audio by content. Because playback is not
// tied to a component, switching chats or re-rendering a message can't revoke the blob URL
// out from under it (the cause of mid-play cutoffs). v1 plays one message at a time
// (a new play replaces the current one); the design leaves room for a queue later.
export type VoicePlayState = 'idle' | 'loading' | 'playing';
export type VoiceSnapshot = { state: VoicePlayState; error: string | null };
const IDLE: VoiceSnapshot = { state: 'idle', error: null };
const CACHE_MAX = 24;
const CLIENT_TIMEOUT_MS = 330000; // backstop; the server proxy already times out at 5 min
// Stable id / cache key from the text and voice settings that affect its audio (djb2).
export function voiceId(content: string, signature = voiceConfigSignature()): string {
const input = JSON.stringify([content, signature]);
let h = 5381;
for (let i = 0; i < input.length; i++) h = (((h << 5) + h) + input.charCodeAt(i)) | 0;
return (h >>> 0).toString(36);
}
class VoicePlayer {
private audio: HTMLAudioElement | null = null;
private unlocked = false;
private cache = new Map<string, string>(); // id -> blob URL (insertion order = LRU)
private currentId: string | null = null;
private state: VoicePlayState = 'idle';
private errorId: string | null = null;
private errorMsg: string | null = null;
private token = 0; // bumps to ignore stale in-flight results
private activeController: AbortController | null = null; // aborts the in-flight TTS fetch
private errorTimer: ReturnType<typeof setTimeout> | null = null;
private listeners = new Set<() => void>();
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private emit() {
this.listeners.forEach((l) => l());
}
getSnapshot(id: string): VoiceSnapshot {
const state = this.currentId === id ? this.state : 'idle';
const error = this.errorId === id ? this.errorMsg : null;
if (state === 'idle' && error === null) return IDLE;
return { state, error };
}
private ensureAudio(): HTMLAudioElement {
if (!this.audio) {
const audio = new Audio();
audio.addEventListener('ended', () => this.onEnded());
audio.addEventListener('error', () => {
// Only meaningful while we believe we're playing.
if (this.state === 'playing') this.onEnded();
});
this.audio = audio;
}
return this.audio;
}
// Call synchronously from the click handler so iOS grants the (reused) element playback.
unlock() {
if (this.unlocked) return;
const audio = this.ensureAudio();
try {
const p = audio.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
audio.pause();
} catch {
/* priming attempt; ignore */
}
this.unlocked = true;
}
toggle(content: string) {
const id = voiceId(content);
if (this.currentId === id && (this.state === 'playing' || this.state === 'loading')) {
this.stop();
return;
}
void this.play(id, content);
}
stop() {
this.token++; // ignore any stale in-flight result
this.abortActive(); // and actually cancel the network request
if (this.audio) this.audio.pause();
this.state = 'idle';
this.currentId = null;
this.emit();
}
private abortActive() {
if (this.activeController) {
this.activeController.abort();
this.activeController = null;
}
}
private onEnded() {
this.state = 'idle';
this.currentId = null;
this.emit();
// (queue auto-advance would hook in here)
}
private setError(id: string, msg: string) {
this.state = 'idle';
this.currentId = id;
this.errorId = id;
this.errorMsg = msg;
this.emit();
if (this.errorTimer) clearTimeout(this.errorTimer);
this.errorTimer = setTimeout(() => {
if (this.errorId === id) {
this.errorId = null;
this.errorMsg = null;
if (this.currentId === id) this.currentId = null;
this.emit();
}
}, 6000);
}
private async play(id: string, content: string) {
const audio = this.ensureAudio();
audio.pause();
this.currentId = id;
this.errorId = null;
this.errorMsg = null;
this.state = 'loading';
this.emit();
const myToken = ++this.token;
this.abortActive(); // cancel any request this play supersedes
try {
let url = this.cache.get(id);
if (!url) {
const controller = new AbortController();
this.activeController = controller;
const timer = setTimeout(() => controller.abort(), CLIENT_TIMEOUT_MS);
const res = await synthesizeVoice(content, controller.signal).finally(() => {
clearTimeout(timer);
if (this.activeController === controller) this.activeController = null;
});
if (myToken !== this.token) return; // superseded by another play/stop
if (!res.ok) {
let msg = `Read-aloud failed (${res.status})`;
try {
const j = await res.json();
if (j?.error) msg = String(j.error);
} catch {
/* non-JSON error body */
}
throw new Error(msg);
}
const blob = await res.blob();
if (myToken !== this.token) return;
url = URL.createObjectURL(blob);
this.cacheSet(id, url);
}
if (myToken !== this.token) return;
audio.src = url;
audio.load();
await audio.play();
if (myToken !== this.token) return;
this.state = 'playing';
this.emit();
} catch (e) {
if (myToken !== this.token) return;
const aborted = e instanceof Error && e.name === 'AbortError';
this.setError(id, aborted ? 'Read-aloud timed out.' : e instanceof Error ? e.message : 'Read-aloud failed');
}
}
private cacheSet(id: string, url: string) {
this.cache.set(id, url);
while (this.cache.size > CACHE_MAX) {
const oldest = this.cache.keys().next().value as string | undefined;
if (oldest === undefined) break;
const oldUrl = this.cache.get(oldest);
this.cache.delete(oldest);
if (oldUrl && oldUrl !== this.audio?.src) URL.revokeObjectURL(oldUrl);
}
}
}
export const voicePlayer = new VoicePlayer();

View File

@@ -58,7 +58,7 @@ const playTone = (
oscillator.stop(startsAt + duration + 0.02);
};
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => {
export const playNotificationSound = async ({ force = false } = {}): Promise<void> => {
if (!force && !isNotificationSoundEnabled()) {
return;
}
@@ -81,3 +81,5 @@ export const playChatCompletionSound = async ({ force = false } = {}): Promise<v
console.warn('Unable to play notification sound:', error);
}
};
export const playChatCompletionSound = (options = {}): Promise<void> => playNotificationSound(options);