@svadmin/core 0.6.1 → 0.8.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.6.1",
3
+ "version": "0.8.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,6 +35,8 @@ 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, setChatContext, getChatContext } from './chatProvider.svelte';
39
+ export type { ChatProvider, ChatMessage, ChatContext, ChatAction } from './chatProvider.svelte';
38
40
  export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes } from './theme.svelte';
39
41
  export type { ThemeMode, ColorTheme } from './theme.svelte';
40
42
  export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier } from './unsaved-changes.svelte';
package/src/useCan.ts CHANGED
@@ -39,16 +39,19 @@ export function useCan(resourceOrOptions: string | UseCanOptions, action?: Actio
39
39
  ? { resource: resourceOrOptions, action: action ?? 'list', params }
40
40
  : resourceOrOptions;
41
41
 
42
+ // @tanstack/svelte-query v6 Accessor pattern
42
43
  const query = createQuery<CanResult>(() => ({
43
- queryKey: ['useCan', options.resource, options.action, options.params],
44
+ queryKey: ['useCan', options.resource, options.action, options.params] as const,
44
45
  queryFn: () => canAccessAsync(options.resource, options.action, options.params),
45
46
  enabled: options.queryOptions?.enabled ?? true,
46
47
  staleTime: options.queryOptions?.staleTime ?? 5 * 60 * 1000, // 5 min default cache
47
48
  }));
48
49
 
50
+ // Access via query store — use safe property access with fallbacks
49
51
  return {
50
- get allowed() { return query.data?.can ?? true; },
51
- get reason() { return query.data?.reason; },
52
+ get allowed() { return (query.data as CanResult | undefined)?.can ?? true; },
53
+ get reason() { return (query.data as CanResult | undefined)?.reason; },
52
54
  get isLoading() { return query.isLoading; },
53
55
  };
54
56
  }
57
+