mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
6d268eedde
commit
59e4966dda
22 changed files with 487 additions and 6 deletions
104
apps/daemon/src/app-version.ts
Normal file
104
apps/daemon/src/app-version.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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) -------------------------------------------------
|
||||
|
|
|
|||
60
apps/daemon/tests/app-version.test.ts
Normal file
60
apps/daemon/tests/app-version.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
48
apps/daemon/tests/version-route.test.ts
Normal file
48
apps/daemon/tests/version-route.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'نمونهها',
|
||||
|
|
|
|||
|
|
@ -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': 'サンプル',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Примеры',
|
||||
|
|
|
|||
|
|
@ -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': '示例',
|
||||
|
|
|
|||
|
|
@ -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': '範例',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
11
packages/contracts/src/api/version.ts
Normal file
11
packages/contracts/src/api/version.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export interface AppVersionInfo {
|
||||
version: string;
|
||||
channel: string;
|
||||
packaged: boolean;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
export interface AppVersionResponse {
|
||||
version: AppVersionInfo;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue