mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat: add WeChat design system, login-flow skill, and fix API mode tool_calls bug - Add WeChat design system (design-systems/wechat/) with full brand spec including color palette, typography, and component rules for chat UI - Add login-flow skill (skills/login-flow/) for mobile authentication flows with P0 checklist, example HTML, and i18n registration across 3 locales - Fix DeepSeek V4 bug: API/BYOK mode (streamFormat=plain) models now receive a directive to emit only <artifact> HTML blocks and suppress tool_calls, since plain adapters proxy to external providers that cannot execute tools * fix: restore full server.ts and WeChat DESIGN.md from ad46d8cd commit Restore files that were corrupted in PR #1083 head branch. The WeChat DESIGN.md was reduced to a single line (filename only) and server.ts was reduced to ~1 line. Both are restored to their original ad46d8cd state with full content. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: restore full server.ts and WeChat DESIGN.md from ad46d8cd Restore files corrupted in PR #1083: - apps/daemon/src/server.ts: restored 7106-line file - design-systems/wechat/DESIGN.md: restored 301-line WeChat design spec - skills/login-flow/SKILL.md: restored from local working state - skills/login-flow/example.html: restored 351-line example HTML * fix: only suppress tool_calls when streamFormat='plain' explicitly, remove nonexistent assets/template.html 1. streamFormat check now requires explicit 'plain' value instead of defaulting to 'plain' when undefined. This prevents normal tool-using chat runs from incorrectly inheriting the API/BYOK tool_calls suppression rule. 2. login-flow SKILL.md: removed reference to assets/template.html since that file does not exist in the skill bundle and derivePreflight() would inject a hard instruction to read it before any other tool, causing pre-flight to fail. * fix: thread streamFormat to composeSystemPrompt in server.ts call Previously the composeSystemPrompt call at line ~4940 omitted streamFormat, causing the composer to default to 'plain' and suppress tool_calls even for tool-using chat runs. Now streamFormat is passed through from the adapter definition so the API mode rule only fires when streamFormat='plain' is explicitly set. * fix: WeChat category metadata, font-family, and login-flow example interactivity WeChat DESIGN.md: - Add Category: Social & Messaging metadata so it appears correctly in picker - Fix font-family declaration: remove invalid -webkit-font-family prefix, use standard font-family so downstream CSS generation works correctly skills/login-flow/example.html: - Add password toggle click handler so show/hide actually works - Change Apple icon fill from hardcoded #fff to currentColor so it is visible on light backgrounds * fix: mirror streamFormat suppression in contracts composer and add WeChat i18n 1. packages/contracts/src/prompts/system.ts: Add streamFormat parameter to ComposeInput and ComposeInput interface, mirroring the same suppression rule from daemon prompts/system.ts. When streamFormat='plain' is passed, a directive is appended telling models not to emit tool_calls and to only output <artifact> HTML blocks. 2. apps/web/src/i18n/content.{ts,fr,ru}.ts: Add WeChat design system entries: - Add 'wechat' to DE/FR/RU_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK arrays - Add 'wechat' summary to DE/FR/RU_DESIGN_SYSTEM_SUMMARIES - Add 'Social & Messaging' category to DE/FR/RU_DESIGN_SYSTEM_CATEGORIES (matching the Category: Social & Messaging metadata in WeChat DESIGN.md) * fix: thread streamFormat='plain' into web composeSystemPrompt for api mode * test: focus localized content coverage on missing resources --------- Co-authored-by: Open Design Contributor <z@open-design.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: mrcfps <mrc@powerformer.com>
199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
declare global {
|
|
interface ImportMeta {
|
|
glob<T = unknown>(pattern: string, options: { eager: true }): Record<string, T>;
|
|
}
|
|
}
|
|
|
|
type LocalizedContentIds = {
|
|
skills: string[];
|
|
designSystems: string[];
|
|
designSystemCategories: string[];
|
|
promptTemplates: string[];
|
|
promptTemplateCategories: string[];
|
|
promptTemplateTags: string[];
|
|
};
|
|
|
|
type LocalizedContentModule = {
|
|
LOCALIZED_CONTENT_IDS: Record<string, LocalizedContentIds>;
|
|
};
|
|
|
|
const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
const webContentModules = import.meta.glob<LocalizedContentModule>(
|
|
'../../apps/web/src/i18n/content.ts',
|
|
{ eager: true },
|
|
);
|
|
const localizedContentModule = Object.values(webContentModules)[0];
|
|
|
|
if (localizedContentModule == null) {
|
|
throw new Error('Failed to load apps/web localized content ids');
|
|
}
|
|
|
|
const { LOCALIZED_CONTENT_IDS } = localizedContentModule;
|
|
|
|
function sorted(values: Iterable<string>): string[] {
|
|
return [...values].sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
function uniqueSorted(values: Iterable<string>): string[] {
|
|
return sorted(new Set(values));
|
|
}
|
|
|
|
function findMissingIds(localizedIds: Iterable<string>, discoveredIds: Iterable<string>): string[] {
|
|
const localized = new Set(localizedIds);
|
|
const discovered = new Set(discoveredIds);
|
|
return sorted([...discovered].filter((id) => !localized.has(id)));
|
|
}
|
|
|
|
function expectExactResourceCoverage(
|
|
label: string,
|
|
localizedIds: Iterable<string>,
|
|
discoveredIds: Iterable<string>,
|
|
): void {
|
|
const missing = findMissingIds(localizedIds, discoveredIds);
|
|
expect(missing, `${label} should cover every discovered resource`).toEqual([]);
|
|
}
|
|
|
|
async function entriesWithFile(root: string, fileName: string): Promise<string[]> {
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
const ids: string[] = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const filePath = path.join(root, entry.name, fileName);
|
|
try {
|
|
if ((await stat(filePath)).isFile()) {
|
|
ids.push(entry.name);
|
|
}
|
|
} catch {
|
|
// Missing optional registry files are ignored, matching resource discovery.
|
|
}
|
|
}
|
|
return sorted(ids);
|
|
}
|
|
|
|
async function readSkillIds(): Promise<string[]> {
|
|
const skillsRoot = path.join(repoRoot, 'skills');
|
|
const dirs = await entriesWithFile(skillsRoot, 'SKILL.md');
|
|
const ids = await Promise.all(
|
|
dirs.map(async (dir) => {
|
|
const raw = await readFile(path.join(skillsRoot, dir, 'SKILL.md'), 'utf8');
|
|
return readFrontmatterName(raw) ?? dir;
|
|
}),
|
|
);
|
|
return sorted(ids);
|
|
}
|
|
|
|
async function readDesignSystemIds(): Promise<string[]> {
|
|
return entriesWithFile(path.join(repoRoot, 'design-systems'), 'DESIGN.md');
|
|
}
|
|
|
|
async function readDesignSystemCategories(): Promise<string[]> {
|
|
const systemsRoot = path.join(repoRoot, 'design-systems');
|
|
const ids = await readDesignSystemIds();
|
|
const categories = await Promise.all(
|
|
ids.map(async (id) => {
|
|
const raw = await readFile(path.join(systemsRoot, id, 'DESIGN.md'), 'utf8');
|
|
return /^>\s*Category:\s*(.+?)\s*$/im.exec(raw)?.[1] ?? 'Uncategorized';
|
|
}),
|
|
);
|
|
return sorted(new Set(categories));
|
|
}
|
|
|
|
async function readPromptTemplateSummaries(): Promise<
|
|
Array<{ id: string; category: string; tags: string[] }>
|
|
> {
|
|
const templatesRoot = path.join(repoRoot, 'prompt-templates');
|
|
const summaries: Array<{ id: string; category: string; tags: string[] }> = [];
|
|
for (const surface of ['image', 'video']) {
|
|
const dir = path.join(templatesRoot, surface);
|
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
const raw = JSON.parse(await readFile(path.join(dir, entry.name), 'utf8')) as {
|
|
id?: unknown;
|
|
category?: unknown;
|
|
tags?: unknown;
|
|
};
|
|
if (typeof raw.id !== 'string' || !raw.id) continue;
|
|
summaries.push({
|
|
id: raw.id,
|
|
category: typeof raw.category === 'string' ? raw.category : 'General',
|
|
tags: Array.isArray(raw.tags) ? raw.tags.filter((tag): tag is string => typeof tag === 'string') : [],
|
|
});
|
|
}
|
|
}
|
|
return summaries;
|
|
}
|
|
|
|
function readFrontmatterName(src: string): string | null {
|
|
const text = src.replace(/^\uFEFF/, '');
|
|
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
if (match == null) return null;
|
|
const nameMatch = /^name:\s*(.*?)\s*$/im.exec(match[1] ?? '');
|
|
if (nameMatch == null) return null;
|
|
const name = unquoteYamlScalar(nameMatch[1] ?? '').trim();
|
|
return name || null;
|
|
}
|
|
|
|
function unquoteYamlScalar(value: string): string {
|
|
const trimmed = value.trim();
|
|
if (
|
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
) {
|
|
return trimmed.slice(1, -1);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
describe('localized display content coverage', () => {
|
|
for (const [locale, ids] of Object.entries(LOCALIZED_CONTENT_IDS)) {
|
|
it(`covers every curated skill, design system, and prompt template for ${locale}`, async () => {
|
|
const [skillIds, designSystemIds, promptTemplateSummaries] = await Promise.all([
|
|
readSkillIds(),
|
|
readDesignSystemIds(),
|
|
readPromptTemplateSummaries(),
|
|
]);
|
|
|
|
expectExactResourceCoverage('skills display copy', ids.skills, skillIds);
|
|
expectExactResourceCoverage(
|
|
'design-system summaries',
|
|
ids.designSystems,
|
|
designSystemIds,
|
|
);
|
|
expectExactResourceCoverage(
|
|
'prompt-template metadata',
|
|
ids.promptTemplates,
|
|
uniqueSorted(promptTemplateSummaries.map((template) => template.id)),
|
|
);
|
|
});
|
|
|
|
it(`covers every curated display category and prompt tag for ${locale}`, async () => {
|
|
const [designSystemCategories, promptTemplateSummaries] = await Promise.all([
|
|
readDesignSystemCategories(),
|
|
readPromptTemplateSummaries(),
|
|
]);
|
|
const promptTemplateCategories = new Set(
|
|
promptTemplateSummaries.map((template) => template.category),
|
|
);
|
|
const promptTemplateTags = new Set(
|
|
promptTemplateSummaries.flatMap((template) => template.tags),
|
|
);
|
|
|
|
expect(sorted(ids.designSystemCategories)).toEqual(
|
|
expect.arrayContaining(designSystemCategories),
|
|
);
|
|
expect(sorted(ids.promptTemplateCategories)).toEqual(
|
|
expect.arrayContaining(sorted(promptTemplateCategories)),
|
|
);
|
|
expect(sorted(ids.promptTemplateTags)).toEqual(
|
|
expect.arrayContaining(sorted(promptTemplateTags)),
|
|
);
|
|
});
|
|
}
|
|
});
|