feat(version): add app version awareness (#204)

* feat(version): add app version awareness

* fix(version): detect packaged sidecars across platforms
This commit is contained in:
Aresdgi 2026-05-01 11:26:54 +02:00 committed by GitHub
parent 6d268eedde
commit 59e4966dda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 487 additions and 6 deletions

View file

@ -0,0 +1,104 @@
import { readFile } from 'node:fs/promises';
export const APP_VERSION_FALLBACK = '0.0.0';
// Keep this structurally aligned with `@open-design/contracts` AppVersionInfo.
// Daemon cannot import the package root type directly yet because its NodeNext
// test typecheck follows the contracts source re-exports and requires explicit
// `.js` extensions across that package.
export interface AppVersionInfo {
version: string;
channel: string;
packaged: boolean;
platform: string;
arch: string;
}
interface PackageMetadata {
version?: unknown;
}
export interface ResolveAppVersionInfoOptions {
env?: NodeJS.ProcessEnv | undefined;
packageMetadata?: PackageMetadata | null;
resourcesPath?: string | undefined;
execPath?: string | undefined;
platform?: NodeJS.Platform | undefined;
arch?: NodeJS.Architecture | undefined;
}
export interface ReadAppVersionInfoOptions extends ResolveAppVersionInfoOptions {
packageJsonUrl?: URL | undefined;
}
const DEFAULT_PACKAGE_JSON_URL = new URL('../package.json', import.meta.url);
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
function cleanString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
export function isPackagedRuntime({
resourcesPath = processWithResources.resourcesPath,
execPath = process.execPath,
platform = process.platform,
}: Pick<ResolveAppVersionInfoOptions, 'resourcesPath' | 'execPath' | 'platform'> = {}): boolean {
if (cleanString(resourcesPath)) return true;
const normalizedExecPath = cleanString(execPath)?.replace(/\\/g, '/').toLowerCase();
if (!normalizedExecPath) return false;
switch (platform) {
case 'darwin':
return normalizedExecPath.includes('/contents/resources/');
case 'win32':
return normalizedExecPath.includes('/resources/') || normalizedExecPath.includes('/app.asar');
case 'linux':
return normalizedExecPath.includes('/usr/share/')
|| normalizedExecPath.includes('/opt/')
|| normalizedExecPath.includes('/resources/');
default:
return normalizedExecPath.includes('/resources/') || normalizedExecPath.includes('/app.asar');
}
}
export function resolveAppVersionInfo({
env = process.env,
packageMetadata,
resourcesPath,
execPath,
platform = process.platform,
arch = process.arch,
}: ResolveAppVersionInfoOptions = {}): AppVersionInfo {
const packaged = isPackagedRuntime({ resourcesPath, execPath, platform });
const version = cleanString(packageMetadata?.version) ?? APP_VERSION_FALLBACK;
const prereleaseChannel = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1]?.split('.')[0] ?? null;
const channel = cleanString(env.OD_RELEASE_CHANNEL)
?? cleanString(env.OD_APP_CHANNEL)
?? prereleaseChannel
?? (packaged ? 'stable' : 'development');
return { version, channel, packaged, platform, arch };
}
async function readPackageMetadata(packageJsonUrl: URL): Promise<PackageMetadata | null> {
try {
const raw = await readFile(packageJsonUrl, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
export async function readCurrentAppVersionInfo({
packageJsonUrl = DEFAULT_PACKAGE_JSON_URL,
packageMetadata,
env,
resourcesPath,
execPath,
platform,
arch,
}: ReadAppVersionInfoOptions = {}): Promise<AppVersionInfo> {
const metadata = packageMetadata ?? await readPackageMetadata(packageJsonUrl);
return resolveAppVersionInfo({ env, packageMetadata: metadata, resourcesPath, execPath, platform, arch });
}

View file

@ -52,6 +52,7 @@ import {
writeProjectFile,
} from './projects.js';
import { validateArtifactManifestInput } from './artifact-manifest.js';
import { readCurrentAppVersionInfo } from './app-version.js';
import {
deleteConversation,
deleteProject as dbDeleteProject,
@ -481,8 +482,14 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
app.use(express.static(STATIC_DIR));
}
app.get('/api/health', (_req, res) => {
res.json({ ok: true, version: '0.1.0' });
app.get('/api/health', async (_req, res) => {
const versionInfo = await readCurrentAppVersionInfo();
res.json({ ok: true, version: versionInfo.version });
});
app.get('/api/version', async (_req, res) => {
const version = await readCurrentAppVersionInfo();
res.json({ version });
});
// ---- Projects (DB-backed) -------------------------------------------------

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import {
APP_VERSION_FALLBACK,
isPackagedRuntime,
resolveAppVersionInfo,
} from '../src/app-version.js';
describe('app version helpers', () => {
it('resolves version info from package metadata', () => {
expect(resolveAppVersionInfo({
packageMetadata: { version: '1.2.3' },
env: {},
resourcesPath: undefined,
execPath: '/usr/local/bin/node',
platform: 'linux',
arch: 'x64',
})).toEqual({
version: '1.2.3',
channel: 'development',
packaged: false,
platform: 'linux',
arch: 'x64',
});
});
it('uses a safe fallback when package metadata is missing', () => {
expect(resolveAppVersionInfo({ packageMetadata: null, env: {} }).version).toBe(APP_VERSION_FALLBACK);
});
it('detects packaged runtimes without sidecar protocol knowledge', () => {
expect(isPackagedRuntime({ resourcesPath: '/Applications/Open Design.app/Contents/Resources' })).toBe(true);
expect(isPackagedRuntime({
execPath: '/Applications/Open Design.app/Contents/Resources/open-design/bin/node',
platform: 'darwin',
})).toBe(true);
expect(isPackagedRuntime({
execPath: 'C:\\Users\\Ada\\AppData\\Local\\Programs\\Open Design\\resources\\open-design\\bin\\node.exe',
platform: 'win32',
})).toBe(true);
expect(isPackagedRuntime({
execPath: '/opt/Open Design/resources/open-design/bin/node',
platform: 'linux',
})).toBe(true);
expect(isPackagedRuntime({ execPath: '/usr/local/bin/node', platform: 'linux' })).toBe(false);
});
it('honors an explicit release channel', () => {
expect(resolveAppVersionInfo({
packageMetadata: { version: '1.2.3' },
env: { OD_RELEASE_CHANNEL: 'beta' },
}).channel).toBe('beta');
});
it('infers prerelease channel from semver metadata', () => {
expect(resolveAppVersionInfo({
packageMetadata: { version: '0.1.0-beta.6' },
env: {},
}).channel).toBe('beta');
});
});

View file

@ -0,0 +1,48 @@
import type http from 'node:http';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
describe('/api/version', () => {
let server: http.Server;
let baseUrl: string;
beforeAll(async () => {
const started = await startServer({ port: 0, returnServer: true }) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it('returns current app version info', async () => {
const res = await fetch(`${baseUrl}/api/version`);
const json = await res.json() as unknown;
expect(res.ok).toBe(true);
expect(json).toEqual({
version: {
version: expect.any(String),
channel: expect.any(String),
packaged: expect.any(Boolean),
platform: expect.any(String),
arch: expect.any(String),
},
});
});
it('keeps health version aligned with version endpoint', async () => {
const [healthRes, versionRes] = await Promise.all([
fetch(`${baseUrl}/api/health`),
fetch(`${baseUrl}/api/version`),
]);
const health = await healthRes.json() as { ok?: unknown; version?: unknown };
const version = await versionRes.json() as { version?: { version?: unknown } };
expect(healthRes.ok).toBe(true);
expect(versionRes.ok).toBe(true);
expect(health).toEqual({ ok: true, version: version.version?.version });
});
});

View file

@ -5,6 +5,7 @@ import { ProjectView } from './components/ProjectView';
import { SettingsDialog } from './components/SettingsDialog';
import {
daemonIsLive,
fetchAppVersionInfo,
fetchAgents,
fetchDesignSystems,
fetchPromptTemplates,
@ -28,6 +29,7 @@ import {
import type {
AgentInfo,
AppConfig,
AppVersionInfo,
DesignSystemSummary,
Project,
ProjectTemplate,
@ -46,6 +48,7 @@ export function App() {
const [projects, setProjects] = useState<Project[]>([]);
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
const [promptTemplates, setPromptTemplates] = useState<PromptTemplateSummary[]>([]);
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(null);
// Goes false once the bootstrap effect has finished its initial round of
// fetches. The entry view uses this to show shimmer / skeleton states
// instead of an "empty" page that flickers before data lands.
@ -59,7 +62,7 @@ export function App() {
const alive = await daemonIsLive();
if (cancelled) return;
setDaemonLive(alive);
const [agentList, skillList, dsList, projectList, templateList, promptTemplateList] =
const [agentList, skillList, dsList, projectList, templateList, promptTemplateList, versionInfo] =
await Promise.all([
alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]),
alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]),
@ -69,6 +72,7 @@ export function App() {
alive ? listProjects() : Promise.resolve([] as Project[]),
alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]),
alive ? fetchPromptTemplates() : Promise.resolve([] as PromptTemplateSummary[]),
alive ? fetchAppVersionInfo() : Promise.resolve(null),
]);
if (cancelled) return;
setAgents(agentList);
@ -77,6 +81,7 @@ export function App() {
setProjects(projectList);
setTemplates(templateList);
setPromptTemplates(promptTemplateList);
setAppVersionInfo(versionInfo);
setConfig((prev) => {
const next = { ...prev };
@ -343,6 +348,7 @@ export function App() {
initial={config}
agents={agents}
daemonLive={daemonLive}
appVersionInfo={appVersionInfo}
welcome={settingsWelcome}
onSave={handleConfigSave}
onClose={() => {

View file

@ -10,7 +10,7 @@ import {
renderModelOptions,
} from './modelOptions';
import { KNOWN_PROVIDERS } from '../state/config';
import type { AgentInfo, AppConfig, ExecMode } from '../types';
import type { AgentInfo, AppConfig, AppVersionInfo, ExecMode } from '../types';
import { MEDIA_PROVIDERS } from '../media/models';
import type { MediaProvider } from '../media/models';
@ -18,6 +18,7 @@ interface Props {
initial: AppConfig;
agents: AgentInfo[];
daemonLive: boolean;
appVersionInfo: AppVersionInfo | null;
welcome?: boolean;
onSave: (cfg: AppConfig) => void;
onClose: () => void;
@ -35,6 +36,7 @@ export function SettingsDialog({
initial,
agents,
daemonLive,
appVersionInfo,
welcome,
onSave,
onClose,
@ -44,7 +46,7 @@ export function SettingsDialog({
const [cfg, setCfg] = useState<AppConfig>(initial);
const [showApiKey, setShowApiKey] = useState(false);
const [languageOpen, setLanguageOpen] = useState(false);
const [activeSection, setActiveSection] = useState<'execution' | 'media' | 'language'>('execution');
const [activeSection, setActiveSection] = useState<'execution' | 'media' | 'language' | 'about'>('execution');
const [languageMenuRect, setLanguageMenuRect] = useState<DOMRect | null>(null);
const languageRef = useRef<HTMLDivElement | null>(null);
@ -153,6 +155,17 @@ export function SettingsDialog({
<small>{t('settings.languageHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'about' ? ' active' : ''}`}
onClick={() => setActiveSection('about')}
>
<Icon name="settings" size={18} />
<span>
<strong>{t('settings.about')}</strong>
<small>{t('settings.aboutHint')}</small>
</span>
</button>
</aside>
<div className="settings-content">
{activeSection === 'execution' ? (
@ -508,6 +521,47 @@ export function SettingsDialog({
</div>
</section>
) : null}
{activeSection === 'about' ? (
<section className="settings-section">
<div className="section-head">
<div>
<h3>{t('settings.about')}</h3>
<p className="hint">{t('settings.aboutHint')}</p>
</div>
</div>
{appVersionInfo ? (
<dl className="settings-about-list">
<div>
<dt>{t('settings.appVersion')}</dt>
<dd>{appVersionInfo.version}</dd>
</div>
<div>
<dt>{t('settings.appChannel')}</dt>
<dd>{appVersionInfo.channel}</dd>
</div>
<div>
<dt>{t('settings.appRuntime')}</dt>
<dd>
{appVersionInfo.packaged
? t('settings.runtimePackaged')
: t('settings.runtimeDevelopment')}
</dd>
</div>
<div>
<dt>{t('settings.appPlatform')}</dt>
<dd>{appVersionInfo.platform}</dd>
</div>
<div>
<dt>{t('settings.appArchitecture')}</dt>
<dd>{appVersionInfo.arch}</dd>
</div>
</dl>
) : (
<div className="empty-card">{t('settings.versionUnavailable')}</div>
)}
</section>
) : null}
</div>
</div>

View file

@ -100,6 +100,16 @@ export const de: Dict = {
'settings.mediaProviderClear': 'Leeren',
'settings.mediaProviderPlaceholder': 'API-Key einfügen',
'settings.mediaProviderBaseUrlPlaceholder': 'Standard-Base-URL überschreiben',
'settings.about': 'Info',
'settings.aboutHint': 'Version und Laufzeitdetails',
'settings.appVersion': 'Version',
'settings.appChannel': 'Kanal',
'settings.appRuntime': 'Laufzeit',
'settings.appPlatform': 'Plattform',
'settings.appArchitecture': 'Architektur',
'settings.runtimePackaged': 'Paketierte App',
'settings.runtimeDevelopment': 'Entwicklung',
'settings.versionUnavailable': 'Versionsdetails sind nicht verfügbar, solange der Daemon offline ist.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Beispiele',

View file

@ -100,6 +100,16 @@ export const en: Dict = {
'settings.mediaProviderClear': 'Clear',
'settings.mediaProviderPlaceholder': 'Paste API key',
'settings.mediaProviderBaseUrlPlaceholder': 'Override default base URL',
'settings.about': 'About',
'settings.aboutHint': 'Version and runtime details',
'settings.appVersion': 'Version',
'settings.appChannel': 'Channel',
'settings.appRuntime': 'Runtime',
'settings.appPlatform': 'Platform',
'settings.appArchitecture': 'Architecture',
'settings.runtimePackaged': 'Packaged app',
'settings.runtimeDevelopment': 'Development',
'settings.versionUnavailable': 'Version details are unavailable while the daemon is offline.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Examples',

View file

@ -100,6 +100,16 @@ export const esES: Dict = {
'settings.mediaProviderClear': 'Limpiar',
'settings.mediaProviderPlaceholder': 'Pega la clave de API',
'settings.mediaProviderBaseUrlPlaceholder': 'Sobrescribir URL base por defecto',
'settings.about': 'Acerca de',
'settings.aboutHint': 'Versión y detalles de ejecución',
'settings.appVersion': 'Versión',
'settings.appChannel': 'Canal',
'settings.appRuntime': 'Ejecución',
'settings.appPlatform': 'Plataforma',
'settings.appArchitecture': 'Arquitectura',
'settings.runtimePackaged': 'App empaquetada',
'settings.runtimeDevelopment': 'Desarrollo',
'settings.versionUnavailable': 'Los detalles de versión no están disponibles mientras el daemon está offline.',
'entry.tabDesigns': 'Diseños',
'entry.tabExamples': 'Ejemplos',

View file

@ -100,6 +100,16 @@ export const fa: Dict = {
'settings.mediaProviderClear': 'پاک کردن',
'settings.mediaProviderPlaceholder': 'کلید API را وارد کنید',
'settings.mediaProviderBaseUrlPlaceholder': 'بازنویسی آدرس پایه پیش‌فرض',
'settings.about': 'درباره',
'settings.aboutHint': 'جزئیات نسخه و اجرا',
'settings.appVersion': 'نسخه',
'settings.appChannel': 'کانال',
'settings.appRuntime': 'محیط اجرا',
'settings.appPlatform': 'سکو',
'settings.appArchitecture': 'معماری',
'settings.runtimePackaged': 'برنامه بسته‌بندی‌شده',
'settings.runtimeDevelopment': 'توسعه',
'settings.versionUnavailable': 'تا وقتی daemon آفلاین است جزئیات نسخه در دسترس نیست.',
'entry.tabDesigns': 'طرح‌ها',
'entry.tabExamples': 'نمونه‌ها',

View file

@ -100,6 +100,16 @@ export const ja: Dict = {
'settings.mediaProviderClear': 'クリア',
'settings.mediaProviderPlaceholder': 'APIキーを貼り付け',
'settings.mediaProviderBaseUrlPlaceholder': 'デフォルトのベース URL を上書き',
'settings.about': 'About',
'settings.aboutHint': 'バージョンと実行環境の詳細',
'settings.appVersion': 'バージョン',
'settings.appChannel': 'チャンネル',
'settings.appRuntime': '実行環境',
'settings.appPlatform': 'プラットフォーム',
'settings.appArchitecture': 'アーキテクチャ',
'settings.runtimePackaged': 'パッケージ版アプリ',
'settings.runtimeDevelopment': '開発環境',
'settings.versionUnavailable': 'daemon がオフラインの間はバージョン詳細を取得できません。',
'entry.tabDesigns': 'デザイン',
'entry.tabExamples': 'サンプル',

View file

@ -99,6 +99,16 @@ export const ptBR: Dict = {
'settings.mediaProviderClear': 'Limpar',
'settings.mediaProviderPlaceholder': 'Cole a API key',
'settings.mediaProviderBaseUrlPlaceholder': 'Sobrescrever Base URL padrão',
'settings.about': 'Sobre',
'settings.aboutHint': 'Versão e detalhes de execução',
'settings.appVersion': 'Versão',
'settings.appChannel': 'Canal',
'settings.appRuntime': 'Runtime',
'settings.appPlatform': 'Plataforma',
'settings.appArchitecture': 'Arquitetura',
'settings.runtimePackaged': 'App empacotado',
'settings.runtimeDevelopment': 'Desenvolvimento',
'settings.versionUnavailable': 'Os detalhes de versão ficam indisponíveis enquanto o daemon está offline.',
'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Exemplos',

View file

@ -99,6 +99,16 @@ export const ru: Dict = {
'settings.mediaProviderClear': 'Очистить',
'settings.mediaProviderPlaceholder': 'Вставьте API key',
'settings.mediaProviderBaseUrlPlaceholder': 'Переопределить Base URL',
'settings.about': 'О приложении',
'settings.aboutHint': 'Версия и сведения о запуске',
'settings.appVersion': 'Версия',
'settings.appChannel': 'Канал',
'settings.appRuntime': 'Среда запуска',
'settings.appPlatform': 'Платформа',
'settings.appArchitecture': 'Архитектура',
'settings.runtimePackaged': 'Упакованное приложение',
'settings.runtimeDevelopment': 'Разработка',
'settings.versionUnavailable': 'Сведения о версии недоступны, пока daemon не запущен.',
'entry.tabDesigns': 'Дизайны',
'entry.tabExamples': 'Примеры',

View file

@ -98,6 +98,16 @@ export const zhCN: Dict = {
'settings.mediaProviderClear': '清除',
'settings.mediaProviderPlaceholder': '粘贴 API key',
'settings.mediaProviderBaseUrlPlaceholder': '覆盖默认 Base URL',
'settings.about': '关于',
'settings.aboutHint': '版本和运行时详情',
'settings.appVersion': '版本',
'settings.appChannel': '渠道',
'settings.appRuntime': '运行时',
'settings.appPlatform': '平台',
'settings.appArchitecture': '架构',
'settings.runtimePackaged': '已打包应用',
'settings.runtimeDevelopment': '开发环境',
'settings.versionUnavailable': '守护进程离线时无法获取版本详情。',
'entry.tabDesigns': '我的设计',
'entry.tabExamples': '示例',

View file

@ -98,6 +98,16 @@ export const zhTW: Dict = {
'settings.mediaProviderClear': '清除',
'settings.mediaProviderPlaceholder': '貼上 API key',
'settings.mediaProviderBaseUrlPlaceholder': '覆蓋預設 Base URL',
'settings.about': '關於',
'settings.aboutHint': '版本與執行環境詳情',
'settings.appVersion': '版本',
'settings.appChannel': '通道',
'settings.appRuntime': '執行環境',
'settings.appPlatform': '平台',
'settings.appArchitecture': '架構',
'settings.runtimePackaged': '已封裝應用程式',
'settings.runtimeDevelopment': '開發環境',
'settings.versionUnavailable': '守護程式離線時無法取得版本詳情。',
'entry.tabDesigns': '我的設計',
'entry.tabExamples': '範例',

View file

@ -115,6 +115,16 @@ export interface Dict {
'settings.mediaProviderClear': string;
'settings.mediaProviderPlaceholder': string;
'settings.mediaProviderBaseUrlPlaceholder': string;
'settings.about': string;
'settings.aboutHint': string;
'settings.appVersion': string;
'settings.appChannel': string;
'settings.appRuntime': string;
'settings.appPlatform': string;
'settings.appArchitecture': string;
'settings.runtimePackaged': string;
'settings.runtimeDevelopment': string;
'settings.versionUnavailable': string;
// Entry view / tabs
'entry.tabDesigns': string;

View file

@ -917,6 +917,34 @@ code {
.settings-section { display: flex; flex-direction: column; gap: 12px; }
.settings-about-list {
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.settings-about-list > div {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.settings-about-list dt {
font-size: 12px;
color: var(--text-muted);
}
.settings-about-list dd {
margin: 0;
color: var(--text);
font-size: 13px;
font-weight: 600;
text-align: right;
overflow-wrap: anywhere;
}
.media-provider-list {
display: flex;
flex-direction: column;

View file

@ -1,6 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fetchProjectFileText } from './registry';
import { fetchAppVersionInfo, fetchProjectFileText } from './registry';
describe('fetchAppVersionInfo', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('returns version info from the daemon response', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({
version: { version: '1.2.3', channel: 'beta', packaged: true, platform: 'darwin', arch: 'arm64' },
}), { status: 200 })),
);
await expect(fetchAppVersionInfo()).resolves.toEqual({
version: '1.2.3',
channel: 'beta',
packaged: true,
platform: 'darwin',
arch: 'arm64',
});
});
it('returns null when version info is unavailable or malformed', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ version: { version: '1.2.3' } }), { status: 200 })),
);
await expect(fetchAppVersionInfo()).resolves.toBeNull();
});
});
describe('fetchProjectFileText', () => {
afterEach(() => {

View file

@ -1,5 +1,7 @@
import type {
AgentInfo,
AppVersionInfo,
AppVersionResponse,
ChatAttachment,
DeployConfigResponse,
DeployProjectFileResponse,
@ -104,6 +106,29 @@ export async function daemonIsLive(): Promise<boolean> {
}
}
function isAppVersionInfo(value: unknown): value is AppVersionInfo {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<AppVersionInfo>;
return (
typeof candidate.version === 'string' &&
typeof candidate.channel === 'string' &&
typeof candidate.packaged === 'boolean' &&
typeof candidate.platform === 'string' &&
typeof candidate.arch === 'string'
);
}
export async function fetchAppVersionInfo(): Promise<AppVersionInfo | null> {
try {
const resp = await fetch('/api/version');
if (!resp.ok) return null;
const json = (await resp.json()) as Partial<AppVersionResponse>;
return isAppVersionInfo(json.version) ? json.version : null;
} catch {
return null;
}
}
export async function fetchSkillExample(id: string): Promise<string | null> {
try {
const resp = await fetch(`/api/skills/${encodeURIComponent(id)}/example`);

View file

@ -1,5 +1,7 @@
import type {
AgentInfo,
AppVersionInfo,
AppVersionResponse,
AudioKind,
ChatAttachment,
ChatMessage,
@ -110,6 +112,8 @@ export interface PromptTemplateDetail extends PromptTemplateSummary {
export type {
AgentInfo,
AppVersionInfo,
AppVersionResponse,
AudioKind,
Conversation,
DeployConfigResponse,

View file

@ -0,0 +1,11 @@
export interface AppVersionInfo {
version: string;
channel: string;
packaged: boolean;
platform: string;
arch: string;
}
export interface AppVersionResponse {
version: AppVersionInfo;
}

View file

@ -7,6 +7,7 @@ export * from './api/files';
export * from './api/projects';
export * from './api/proxy';
export * from './api/registry';
export * from './api/version';
export * from './sse/common';
export * from './sse/chat';
export * from './sse/proxy';