@svadmin/core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Core SDK — hooks, types, context, i18n, permissions, router",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -0,0 +1,65 @@
1
+ // Chat provider — headless interface for AI chat integration
2
+
3
+ /** Describes an actionable button rendered in assistant messages (tool-call UX). */
4
+ export interface ChatAction {
5
+ label: string;
6
+ /** Optional variant for styling (e.g. 'destructive' renders red) */
7
+ variant?: 'default' | 'destructive' | 'outline';
8
+ /** Arbitrary payload forwarded to the onAction handler */
9
+ payload?: Record<string, unknown>;
10
+ }
11
+
12
+ export interface ChatMessage {
13
+ id: string;
14
+ role: 'user' | 'assistant' | 'system';
15
+ content: string;
16
+ timestamp: number;
17
+ /** Tool-call action buttons rendered below the message bubble */
18
+ actions?: ChatAction[];
19
+ }
20
+
21
+ /** Admin context automatically injected into chat system messages. */
22
+ export interface ChatContext {
23
+ currentResource?: string;
24
+ selectedRecordId?: string;
25
+ currentView?: 'list' | 'edit' | 'create' | 'show';
26
+ pathname?: string;
27
+ }
28
+
29
+ /**
30
+ * ChatProvider interface for integrating AI chat into admin panels.
31
+ * Implement `sendMessage` to connect to any AI backend (OpenAI, self-hosted, etc.)
32
+ *
33
+ * Return a `string` for non-streaming responses, or an `AsyncGenerator<string>`
34
+ * for streaming (SSE / chunked) responses.
35
+ */
36
+ export interface ChatProvider {
37
+ sendMessage(
38
+ messages: ChatMessage[],
39
+ options?: { signal?: AbortSignal },
40
+ ): Promise<string> | AsyncGenerator<string, void, unknown>;
41
+ }
42
+
43
+ // ─── Chat Provider singleton ───────────────────────────────────
44
+
45
+ let chatProvider: ChatProvider | null = $state(null);
46
+
47
+ export function setChatProvider(provider: ChatProvider): void {
48
+ chatProvider = provider;
49
+ }
50
+
51
+ export function getChatProvider(): ChatProvider | null {
52
+ return chatProvider;
53
+ }
54
+
55
+ // ─── Chat Context singleton ────────────────────────────────────
56
+
57
+ let chatContext = $state<ChatContext>({});
58
+
59
+ export function setChatContext(ctx: ChatContext): void {
60
+ chatContext = ctx;
61
+ }
62
+
63
+ export function getChatContext(): ChatContext {
64
+ return chatContext;
65
+ }
@@ -0,0 +1,168 @@
1
+ // Unit tests for ChatProvider types and ChatContext logic
2
+ // Since chatProvider.svelte.ts uses $state runes (requires Svelte compiler),
3
+ // we test the pure type shapes and serialization here — matching the project's
4
+ // pattern in i18n.test.ts of testing extracted logic.
5
+
6
+ import { describe, test, expect } from 'bun:test';
7
+
8
+ /** Mirror of ChatAction from chatProvider.svelte.ts */
9
+ interface ChatAction {
10
+ label: string;
11
+ variant?: 'default' | 'destructive' | 'outline';
12
+ payload?: Record<string, unknown>;
13
+ }
14
+
15
+ /** Mirror of ChatMessage from chatProvider.svelte.ts */
16
+ interface ChatMessage {
17
+ id: string;
18
+ role: 'user' | 'assistant' | 'system';
19
+ content: string;
20
+ timestamp: number;
21
+ actions?: ChatAction[];
22
+ }
23
+
24
+ /** Mirror of ChatContext from chatProvider.svelte.ts */
25
+ interface ChatContext {
26
+ currentResource?: string;
27
+ selectedRecordId?: string;
28
+ currentView?: 'list' | 'edit' | 'create' | 'show';
29
+ pathname?: string;
30
+ }
31
+
32
+ /** Build context system message (mirrors logic in ChatDialog.svelte) */
33
+ function buildContextSystemMessage(ctx: ChatContext): ChatMessage | null {
34
+ if (!ctx.currentResource) return null;
35
+
36
+ const parts: string[] = [];
37
+ parts.push(`Resource: ${ctx.currentResource}`);
38
+ if (ctx.currentView) parts.push(`View: ${ctx.currentView}`);
39
+ if (ctx.selectedRecordId) parts.push(`Selected Record ID: ${ctx.selectedRecordId}`);
40
+ if (ctx.pathname) parts.push(`Path: ${ctx.pathname}`);
41
+
42
+ return {
43
+ id: 'ctx-system',
44
+ role: 'system',
45
+ content: `[Admin Context] ${parts.join(' | ')}`,
46
+ timestamp: Date.now(),
47
+ };
48
+ }
49
+
50
+ describe('ChatMessage with actions', () => {
51
+ test('message with actions serializes to JSON and back', () => {
52
+ const actions: ChatAction[] = [
53
+ { label: 'Delete Record', variant: 'destructive', payload: { id: '42' } },
54
+ { label: 'View Details' },
55
+ ];
56
+ const msg: ChatMessage = {
57
+ id: 'msg-1',
58
+ role: 'assistant',
59
+ content: 'I found a record to delete.',
60
+ timestamp: 1711500000000,
61
+ actions,
62
+ };
63
+
64
+ const serialized = JSON.stringify(msg);
65
+ const deserialized = JSON.parse(serialized) as ChatMessage;
66
+
67
+ expect(deserialized.actions).toHaveLength(2);
68
+ expect(deserialized.actions![0].label).toBe('Delete Record');
69
+ expect(deserialized.actions![0].variant).toBe('destructive');
70
+ expect(deserialized.actions![0].payload).toEqual({ id: '42' });
71
+ expect(deserialized.actions![1].variant).toBeUndefined();
72
+ });
73
+
74
+ test('message without actions has undefined actions', () => {
75
+ const msg: ChatMessage = {
76
+ id: 'msg-2',
77
+ role: 'user',
78
+ content: 'Hello',
79
+ timestamp: 1711500000000,
80
+ };
81
+ expect(msg.actions).toBeUndefined();
82
+ });
83
+
84
+ test('system message persists correctly', () => {
85
+ const msg: ChatMessage = {
86
+ id: 'ctx-1',
87
+ role: 'system',
88
+ content: '[Admin Context] Resource: posts | View: edit',
89
+ timestamp: 1711500000000,
90
+ };
91
+ const parsed = JSON.parse(JSON.stringify(msg)) as ChatMessage;
92
+ expect(parsed.role).toBe('system');
93
+ expect(parsed.content).toContain('posts');
94
+ });
95
+ });
96
+
97
+ describe('buildContextSystemMessage', () => {
98
+ test('returns null when no resource', () => {
99
+ expect(buildContextSystemMessage({})).toBeNull();
100
+ });
101
+
102
+ test('builds message with resource only', () => {
103
+ const msg = buildContextSystemMessage({ currentResource: 'users' });
104
+ expect(msg).not.toBeNull();
105
+ expect(msg!.role).toBe('system');
106
+ expect(msg!.content).toContain('Resource: users');
107
+ });
108
+
109
+ test('includes all context fields', () => {
110
+ const msg = buildContextSystemMessage({
111
+ currentResource: 'posts',
112
+ currentView: 'edit',
113
+ selectedRecordId: '42',
114
+ pathname: '/posts/edit/42',
115
+ });
116
+ expect(msg!.content).toContain('Resource: posts');
117
+ expect(msg!.content).toContain('View: edit');
118
+ expect(msg!.content).toContain('Selected Record ID: 42');
119
+ expect(msg!.content).toContain('Path: /posts/edit/42');
120
+ });
121
+
122
+ test('omits undefined fields', () => {
123
+ const msg = buildContextSystemMessage({
124
+ currentResource: 'users',
125
+ currentView: 'list',
126
+ });
127
+ expect(msg!.content).not.toContain('Selected Record ID');
128
+ expect(msg!.content).not.toContain('Path');
129
+ });
130
+ });
131
+
132
+ describe('ChatContext shape', () => {
133
+ test('empty context is valid', () => {
134
+ const ctx: ChatContext = {};
135
+ expect(ctx.currentResource).toBeUndefined();
136
+ });
137
+
138
+ test('full context preserves all fields', () => {
139
+ const ctx: ChatContext = {
140
+ currentResource: 'orders',
141
+ selectedRecordId: '99',
142
+ currentView: 'show',
143
+ pathname: '/orders/show/99',
144
+ };
145
+ expect(ctx.currentResource).toBe('orders');
146
+ expect(ctx.selectedRecordId).toBe('99');
147
+ expect(ctx.currentView).toBe('show');
148
+ expect(ctx.pathname).toBe('/orders/show/99');
149
+ });
150
+ });
151
+
152
+ describe('localStorage persistence simulation', () => {
153
+ test('messages array round-trips through JSON', () => {
154
+ const messages: ChatMessage[] = [
155
+ { id: '1', role: 'user', content: 'Hello', timestamp: 1000 },
156
+ {
157
+ id: '2', role: 'assistant', content: 'Hi!', timestamp: 1001,
158
+ actions: [{ label: 'Create Post', variant: 'default', payload: { resource: 'posts' } }],
159
+ },
160
+ ];
161
+ const json = JSON.stringify(messages);
162
+ const restored = JSON.parse(json) as ChatMessage[];
163
+
164
+ expect(restored).toHaveLength(2);
165
+ expect(restored[0].role).toBe('user');
166
+ expect(restored[1].actions![0].label).toBe('Create Post');
167
+ });
168
+ });
@@ -129,6 +129,12 @@ const locales: Record<string, Locale> = {
129
129
  // DevTools
130
130
  'devtools.title': '开发者工具 (Ctrl+Shift+D)',
131
131
  // Validation
132
+ // Chat
133
+ 'chat.contextInfo': '当前上下文:{resource}',
134
+ 'chat.actionButton': '执行',
135
+ 'chat.historyRestored': '已恢复聊天记录',
136
+ 'chat.historyClear': '聊天记录已清除',
137
+ // Validation
132
138
  'validation.required': '此字段为必填项',
133
139
  'validation.minLength': '最少 {min} 个字符',
134
140
  'validation.maxLength': '最多 {max} 个字符',
@@ -261,6 +267,12 @@ const locales: Record<string, Locale> = {
261
267
  // DevTools
262
268
  'devtools.title': 'DevTools (Ctrl+Shift+D)',
263
269
  // Validation
270
+ // Chat
271
+ 'chat.contextInfo': 'Current context: {resource}',
272
+ 'chat.actionButton': 'Execute',
273
+ 'chat.historyRestored': 'Chat history restored',
274
+ 'chat.historyClear': 'Chat history cleared',
275
+ // Validation
264
276
  'validation.required': 'This field is required',
265
277
  'validation.minLength': 'Minimum {min} characters',
266
278
  'validation.maxLength': 'Maximum {max} characters',
package/src/index.ts CHANGED
@@ -35,10 +35,10 @@ export { notify, closeNotification, setNotificationProvider, getNotificationProv
35
35
  export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation } from './i18n.svelte';
36
36
  export { audit, setAuditHandler, setAuditLogProvider, getAuditLogProvider } from './audit';
37
37
  export type { AuditLogProvider } from './audit';
38
- export { setChatProvider, getChatProvider } from './chatProvider';
39
- export type { ChatProvider, ChatMessage } from './chatProvider';
40
- export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes } from './theme.svelte';
41
- export type { ThemeMode, ColorTheme } from './theme.svelte';
38
+ export { setChatProvider, getChatProvider, setChatContext, getChatContext } from './chatProvider.svelte';
39
+ export type { ChatProvider, ChatMessage, ChatContext, ChatAction } from './chatProvider.svelte';
40
+ export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes, configureTheme, getThemeConfig, clearCssOverrides } from './theme.svelte';
41
+ export type { ThemeMode, ColorTheme, ThemeStrategy, ThemeConfig } from './theme.svelte';
42
42
  export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier } from './unsaved-changes.svelte';
43
43
  export { setAdminOptions, getAdminOptions, getTextTransformers } from './options';
44
44
  export type { AdminOptions, TextTransformers, OvertimeConfig } from './options';
@@ -1,11 +1,75 @@
1
1
  // Theme — dark/light/system mode + color theme management (Svelte 5 runes)
2
+ //
3
+ // Supports two class strategies:
4
+ // - 'standard' (default): adds 'dark' class for dark mode (light-first)
5
+ // - 'dark-first': adds 'light' class for light mode (dark-first, e.g. Elygate-style)
2
6
 
3
7
  export type ThemeMode = 'light' | 'dark' | 'system';
4
8
  export type ColorTheme = 'blue' | 'green' | 'rose' | 'orange' | 'violet' | 'zinc';
5
9
 
10
+ /** Controls how the theme class is applied to <html> */
11
+ export type ThemeStrategy = 'standard' | 'dark-first';
12
+
13
+ export interface ThemeConfig {
14
+ /** Class strategy: 'standard' toggles '.dark', 'dark-first' toggles '.light' */
15
+ strategy?: ThemeStrategy;
16
+ /** Custom CSS variables to inject as overrides on <html> */
17
+ cssOverrides?: Record<string, string>;
18
+ /** Whether to disable the built-in color-scheme attribute */
19
+ disableColorScheme?: boolean;
20
+ }
21
+
6
22
  const STORAGE_KEY = 'svadmin-theme';
7
23
  const COLOR_STORAGE_KEY = 'svadmin-color-theme';
8
24
 
25
+ // ── Theme configuration ──────────────────────────────────
26
+ let themeConfig: ThemeConfig = {};
27
+
28
+ /**
29
+ * Configure the theme system. Must be called before setTheme() or
30
+ * automatically via AdminApp's themeConfig prop.
31
+ */
32
+ export function configureTheme(config: ThemeConfig): void {
33
+ themeConfig = { ...config };
34
+ // Re-apply the current theme with new strategy
35
+ applyTheme(mode);
36
+ // Apply CSS overrides
37
+ if (config.cssOverrides) {
38
+ applyCssOverrides(config.cssOverrides);
39
+ }
40
+ }
41
+
42
+ /** Get current theme configuration */
43
+ export function getThemeConfig(): ThemeConfig {
44
+ return { ...themeConfig };
45
+ }
46
+
47
+ function applyCssOverrides(overrides: Record<string, string>): void {
48
+ if (typeof document === 'undefined') return;
49
+ const root = document.documentElement;
50
+ for (const [key, value] of Object.entries(overrides)) {
51
+ const cssVar = key.startsWith('--') ? key : `--${key}`;
52
+ root.style.setProperty(cssVar, value);
53
+ }
54
+ }
55
+
56
+ /** Remove previously applied CSS overrides */
57
+ export function clearCssOverrides(keys?: string[]): void {
58
+ if (typeof document === 'undefined') return;
59
+ const root = document.documentElement;
60
+ if (keys) {
61
+ for (const key of keys) {
62
+ const cssVar = key.startsWith('--') ? key : `--${key}`;
63
+ root.style.removeProperty(cssVar);
64
+ }
65
+ } else if (themeConfig.cssOverrides) {
66
+ for (const key of Object.keys(themeConfig.cssOverrides)) {
67
+ const cssVar = key.startsWith('--') ? key : `--${key}`;
68
+ root.style.removeProperty(cssVar);
69
+ }
70
+ }
71
+ }
72
+
9
73
  // ── Color themes (display metadata) ──────────────────────
10
74
  export const colorThemes: { id: ColorTheme; label: string; color: string }[] = [
11
75
  { id: 'blue', label: 'Blue', color: '#3b82f6' },
@@ -32,8 +96,24 @@ let mode = $state<ThemeMode>(getStoredTheme());
32
96
 
33
97
  function applyTheme(m: ThemeMode): void {
34
98
  const resolved = m === 'system' ? getSystemPreference() : m;
35
- if (typeof document !== 'undefined') {
36
- document.documentElement.classList.toggle('dark', resolved === 'dark');
99
+ if (typeof document === 'undefined') return;
100
+
101
+ const strategy = themeConfig.strategy ?? 'standard';
102
+ const el = document.documentElement;
103
+
104
+ if (strategy === 'dark-first') {
105
+ // Dark-first: default is dark, add 'light' class for light mode
106
+ el.classList.toggle('light', resolved === 'light');
107
+ el.classList.remove('dark'); // ensure no conflict
108
+ } else {
109
+ // Standard: default is light, add 'dark' class for dark mode
110
+ el.classList.toggle('dark', resolved === 'dark');
111
+ el.classList.remove('light'); // ensure no conflict
112
+ }
113
+
114
+ // Set color-scheme attribute unless disabled
115
+ if (!themeConfig.disableColorScheme) {
116
+ el.style.colorScheme = resolved;
37
117
  }
38
118
  }
39
119
 
@@ -1,32 +0,0 @@
1
- // Chat provider — headless interface for AI chat integration
2
-
3
- export interface ChatMessage {
4
- id: string;
5
- role: 'user' | 'assistant' | 'system';
6
- content: string;
7
- timestamp: number;
8
- }
9
-
10
- /**
11
- * ChatProvider interface for integrating AI chat into admin panels.
12
- * Implement `sendMessage` to connect to any AI backend (OpenAI, self-hosted, etc.)
13
- *
14
- * Return a `string` for non-streaming responses, or an `AsyncGenerator<string>`
15
- * for streaming (SSE / chunked) responses.
16
- */
17
- export interface ChatProvider {
18
- sendMessage(
19
- messages: ChatMessage[],
20
- options?: { signal?: AbortSignal },
21
- ): Promise<string> | AsyncGenerator<string, void, unknown>;
22
- }
23
-
24
- let chatProvider: ChatProvider | null = null;
25
-
26
- export function setChatProvider(provider: ChatProvider): void {
27
- chatProvider = provider;
28
- }
29
-
30
- export function getChatProvider(): ChatProvider | null {
31
- return chatProvider;
32
- }