clawport-ui 0.1.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/.env.example +35 -0
- package/BRANDING.md +131 -0
- package/CLAUDE.md +252 -0
- package/README.md +262 -0
- package/SETUP.md +337 -0
- package/app/agents/[id]/page.tsx +727 -0
- package/app/api/agents/route.ts +12 -0
- package/app/api/chat/[id]/route.ts +139 -0
- package/app/api/cron-runs/route.ts +13 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/kanban/chat/[id]/route.ts +119 -0
- package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
- package/app/api/memory/route.ts +12 -0
- package/app/api/transcribe/route.ts +37 -0
- package/app/api/tts/route.ts +42 -0
- package/app/chat/[id]/page.tsx +10 -0
- package/app/chat/page.tsx +200 -0
- package/app/crons/page.tsx +870 -0
- package/app/docs/page.tsx +399 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +692 -0
- package/app/kanban/page.tsx +327 -0
- package/app/layout.tsx +45 -0
- package/app/memory/page.tsx +685 -0
- package/app/page.tsx +817 -0
- package/app/providers.tsx +37 -0
- package/app/settings/page.tsx +901 -0
- package/app/settings-provider.tsx +209 -0
- package/components/AgentAvatar.tsx +54 -0
- package/components/AgentNode.tsx +122 -0
- package/components/Breadcrumbs.tsx +126 -0
- package/components/DynamicFavicon.tsx +62 -0
- package/components/ErrorState.tsx +97 -0
- package/components/FeedView.tsx +494 -0
- package/components/GlobalSearch.tsx +571 -0
- package/components/GridView.tsx +532 -0
- package/components/ManorMap.tsx +157 -0
- package/components/MobileSidebar.tsx +251 -0
- package/components/NavLinks.tsx +271 -0
- package/components/OnboardingWizard.tsx +1067 -0
- package/components/Sidebar.tsx +115 -0
- package/components/ThemeToggle.tsx +108 -0
- package/components/chat/AgentList.tsx +537 -0
- package/components/chat/ConversationView.tsx +1047 -0
- package/components/chat/FileAttachment.tsx +140 -0
- package/components/chat/MediaPreview.tsx +111 -0
- package/components/chat/VoiceMessage.tsx +139 -0
- package/components/crons/PipelineGraph.tsx +327 -0
- package/components/crons/WeeklySchedule.tsx +630 -0
- package/components/docs/AgentsSection.tsx +209 -0
- package/components/docs/ApiReferenceSection.tsx +256 -0
- package/components/docs/ArchitectureSection.tsx +221 -0
- package/components/docs/ComponentsSection.tsx +253 -0
- package/components/docs/CronSystemSection.tsx +235 -0
- package/components/docs/DocSection.tsx +346 -0
- package/components/docs/GettingStartedSection.tsx +169 -0
- package/components/docs/ThemingSection.tsx +257 -0
- package/components/docs/TroubleshootingSection.tsx +200 -0
- package/components/kanban/AgentPicker.tsx +321 -0
- package/components/kanban/CreateTicketModal.tsx +333 -0
- package/components/kanban/KanbanBoard.tsx +70 -0
- package/components/kanban/KanbanColumn.tsx +166 -0
- package/components/kanban/TicketCard.tsx +245 -0
- package/components/kanban/TicketDetailPanel.tsx +850 -0
- package/components/ui/badge.tsx +48 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/skeleton.tsx +27 -0
- package/components/ui/tabs.tsx +91 -0
- package/components/ui/tooltip.tsx +57 -0
- package/components.json +23 -0
- package/docs/API.md +648 -0
- package/docs/COMPONENTS.md +1059 -0
- package/docs/THEMING.md +795 -0
- package/lib/agents-registry.ts +35 -0
- package/lib/agents.json +282 -0
- package/lib/agents.test.ts +367 -0
- package/lib/agents.ts +32 -0
- package/lib/anthropic.test.ts +422 -0
- package/lib/anthropic.ts +220 -0
- package/lib/api-error.ts +16 -0
- package/lib/audio-recorder.test.ts +72 -0
- package/lib/audio-recorder.ts +169 -0
- package/lib/conversations.test.ts +331 -0
- package/lib/conversations.ts +117 -0
- package/lib/cron-pipelines.test.ts +69 -0
- package/lib/cron-pipelines.ts +58 -0
- package/lib/cron-runs.test.ts +118 -0
- package/lib/cron-runs.ts +67 -0
- package/lib/cron-utils.test.ts +222 -0
- package/lib/cron-utils.ts +160 -0
- package/lib/crons.test.ts +502 -0
- package/lib/crons.ts +114 -0
- package/lib/env.test.ts +44 -0
- package/lib/env.ts +14 -0
- package/lib/kanban/automation.test.ts +245 -0
- package/lib/kanban/automation.ts +143 -0
- package/lib/kanban/chat-store.test.ts +149 -0
- package/lib/kanban/chat-store.ts +81 -0
- package/lib/kanban/store.test.ts +238 -0
- package/lib/kanban/store.ts +98 -0
- package/lib/kanban/types.ts +50 -0
- package/lib/kanban/useAgentWork.ts +78 -0
- package/lib/memory.ts +45 -0
- package/lib/multimodal.test.ts +219 -0
- package/lib/multimodal.ts +68 -0
- package/lib/pipeline.integration.test.ts +343 -0
- package/lib/sanitize.ts +194 -0
- package/lib/settings.test.ts +137 -0
- package/lib/settings.ts +94 -0
- package/lib/styles.ts +24 -0
- package/lib/themes.ts +9 -0
- package/lib/transcribe.test.ts +141 -0
- package/lib/transcribe.ts +111 -0
- package/lib/types.ts +66 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.test.ts +132 -0
- package/lib/validation.ts +80 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/setup.mjs +215 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +17 -0
package/lib/sanitize.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML sanitization and safe markdown rendering utilities.
|
|
3
|
+
*
|
|
4
|
+
* Design:
|
|
5
|
+
* - escapeHtml() handles the 5 critical HTML special characters
|
|
6
|
+
* - MarkdownRenderer is a configurable pipeline: escape first, then transform
|
|
7
|
+
* - Open/Closed: add new renderers via the `rules` array without modifying core
|
|
8
|
+
* - Dependency Inversion: consumers depend on the MarkdownRule interface, not
|
|
9
|
+
* a specific implementation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Core escape function
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const HTML_ESCAPE_MAP: Record<string, string> = {
|
|
17
|
+
"&": "&",
|
|
18
|
+
"<": "<",
|
|
19
|
+
">": ">",
|
|
20
|
+
'"': """,
|
|
21
|
+
"'": "'",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const HTML_ESCAPE_RE = /[&<>"']/g;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Escape all HTML-significant characters so that the string is safe
|
|
28
|
+
* to embed inside an HTML document (both element content and attributes).
|
|
29
|
+
*/
|
|
30
|
+
export function escapeHtml(text: string): string {
|
|
31
|
+
return text.replace(HTML_ESCAPE_RE, (ch) => HTML_ESCAPE_MAP[ch]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Markdown rendering pipeline
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A single markdown-to-HTML transformation rule.
|
|
40
|
+
* Rules are applied in order after the input has already been HTML-escaped.
|
|
41
|
+
*/
|
|
42
|
+
export interface MarkdownRule {
|
|
43
|
+
/** Human-readable name for debugging / extensibility */
|
|
44
|
+
name: string;
|
|
45
|
+
/** Regex to match against the escaped text */
|
|
46
|
+
pattern: RegExp;
|
|
47
|
+
/** Replacement string (may use $1, $2, etc.) */
|
|
48
|
+
replacement: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Default rules that ship with the renderer. */
|
|
52
|
+
export const DEFAULT_MARKDOWN_RULES: MarkdownRule[] = [
|
|
53
|
+
{
|
|
54
|
+
name: "h4",
|
|
55
|
+
pattern: /^#### (.+)$/gm,
|
|
56
|
+
replacement:
|
|
57
|
+
'<h4 class="text-[15px] font-semibold" style="color:var(--text-primary);margin-top:1rem;margin-bottom:0.25rem">$1</h4>',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "h3",
|
|
61
|
+
pattern: /^### (.+)$/gm,
|
|
62
|
+
replacement:
|
|
63
|
+
'<h3 class="text-[17px] font-semibold" style="color:var(--text-primary);margin-top:1.25rem;margin-bottom:0.375rem">$1</h3>',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "h2",
|
|
67
|
+
pattern: /^## (.+)$/gm,
|
|
68
|
+
replacement:
|
|
69
|
+
'<h2 class="text-[22px] font-semibold" style="color:var(--text-primary);margin-top:1.5rem;margin-bottom:0.5rem;padding-bottom:0.25rem;border-bottom:1px solid var(--separator)">$1</h2>',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "h1",
|
|
73
|
+
pattern: /^# (.+)$/gm,
|
|
74
|
+
replacement:
|
|
75
|
+
'<h1 class="text-[28px] font-bold" style="color:var(--text-primary);margin-top:1rem;margin-bottom:0.75rem">$1</h1>',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "bold",
|
|
79
|
+
pattern: /\*\*(.+?)\*\*/g,
|
|
80
|
+
replacement:
|
|
81
|
+
'<strong class="font-semibold" style="color:var(--text-primary)">$1</strong>',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "inline-code",
|
|
85
|
+
pattern: /`([^`]+)`/g,
|
|
86
|
+
replacement:
|
|
87
|
+
'<code style="background:var(--fill-secondary);color:var(--accent);padding:2px 6px;border-radius:6px;font-size:13px;font-family:var(--font-mono)">$1</code>',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "unordered-list",
|
|
91
|
+
pattern: /^- (.+)$/gm,
|
|
92
|
+
replacement:
|
|
93
|
+
'<li class="ml-4 text-[15px] leading-[1.7] list-disc" style="color:var(--text-secondary)">$1</li>',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "ordered-list",
|
|
97
|
+
pattern: /^(\d+)\. (.+)$/gm,
|
|
98
|
+
replacement:
|
|
99
|
+
'<li class="ml-4 text-[15px] leading-[1.7] list-decimal" style="color:var(--text-secondary)">$2</li>',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "paragraph-break",
|
|
103
|
+
pattern: /\n{2,}/g,
|
|
104
|
+
replacement:
|
|
105
|
+
'</p><p class="mb-3" style="color:var(--text-secondary)">',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "line-break",
|
|
109
|
+
pattern: /\n/g,
|
|
110
|
+
replacement: "<br/>",
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
export interface MarkdownRendererOptions {
|
|
115
|
+
/** Override or extend the default rules */
|
|
116
|
+
rules?: MarkdownRule[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Render a plain-text markdown string to safe HTML.
|
|
121
|
+
*
|
|
122
|
+
* The pipeline is:
|
|
123
|
+
* 1. Escape ALL HTML entities (neutralises any injected markup)
|
|
124
|
+
* 2. Apply markdown transformation rules in order
|
|
125
|
+
*
|
|
126
|
+
* Because escaping happens first, captured groups ($1 etc.) only ever
|
|
127
|
+
* contain escaped text — no raw HTML can slip through.
|
|
128
|
+
*/
|
|
129
|
+
export function renderMarkdown(
|
|
130
|
+
text: string,
|
|
131
|
+
options?: MarkdownRendererOptions
|
|
132
|
+
): string {
|
|
133
|
+
const rules = options?.rules ?? DEFAULT_MARKDOWN_RULES;
|
|
134
|
+
|
|
135
|
+
// Step 1 — escape (this is the security boundary)
|
|
136
|
+
let html = escapeHtml(text);
|
|
137
|
+
|
|
138
|
+
// Step 2 — apply markdown transformations on the safe string
|
|
139
|
+
for (const rule of rules) {
|
|
140
|
+
html = html.replace(rule.pattern, rule.replacement);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return html;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// JSON colorizer (safe)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/** Default rules for JSON syntax highlighting (applied after escaping). */
|
|
151
|
+
export const JSON_COLORIZE_RULES: MarkdownRule[] = [
|
|
152
|
+
{
|
|
153
|
+
name: "json-key",
|
|
154
|
+
pattern: /"((?:(?!").)*?)"(?=\s*:)/g,
|
|
155
|
+
replacement:
|
|
156
|
+
'<span style="color:var(--accent)">"$1"</span>',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "json-string-value",
|
|
160
|
+
pattern: /:\s*"((?:(?!").)*?)"/g,
|
|
161
|
+
replacement:
|
|
162
|
+
': <span style="color:var(--system-green)">"$1"</span>',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "json-number",
|
|
166
|
+
pattern: /:\s*(\d+\.?\d*)/g,
|
|
167
|
+
replacement: ': <span style="color:var(--system-blue)">$1</span>',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "json-boolean",
|
|
171
|
+
pattern: /:\s*(true|false)/g,
|
|
172
|
+
replacement: ': <span style="color:#bf5af2">$1</span>',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "json-null",
|
|
176
|
+
pattern: /:\s*(null)/g,
|
|
177
|
+
replacement:
|
|
178
|
+
': <span style="color:var(--text-tertiary)">$1</span>',
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Syntax-highlight a JSON string safely.
|
|
184
|
+
* Escapes HTML first, then applies colorization rules.
|
|
185
|
+
*/
|
|
186
|
+
export function colorizeJson(json: string): string {
|
|
187
|
+
let html = escapeHtml(json);
|
|
188
|
+
|
|
189
|
+
for (const rule of JSON_COLORIZE_RULES) {
|
|
190
|
+
html = html.replace(rule.pattern, rule.replacement);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return html;
|
|
194
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { loadSettings, saveSettings, hexToAccentFill } from './settings'
|
|
3
|
+
|
|
4
|
+
// Mock localStorage
|
|
5
|
+
const store: Record<string, string> = {}
|
|
6
|
+
const localStorageMock = {
|
|
7
|
+
getItem: vi.fn((key: string) => store[key] ?? null),
|
|
8
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
9
|
+
store[key] = value
|
|
10
|
+
}),
|
|
11
|
+
removeItem: vi.fn((key: string) => {
|
|
12
|
+
delete store[key]
|
|
13
|
+
}),
|
|
14
|
+
clear: vi.fn(() => {
|
|
15
|
+
Object.keys(store).forEach((k) => delete store[k])
|
|
16
|
+
}),
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
localStorageMock.clear()
|
|
22
|
+
vi.clearAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('loadSettings', () => {
|
|
26
|
+
it('returns defaults when nothing is stored', () => {
|
|
27
|
+
const s = loadSettings()
|
|
28
|
+
expect(s).toEqual({
|
|
29
|
+
accentColor: null,
|
|
30
|
+
portalName: null,
|
|
31
|
+
portalSubtitle: null,
|
|
32
|
+
portalEmoji: null,
|
|
33
|
+
portalIcon: null,
|
|
34
|
+
iconBgHidden: false,
|
|
35
|
+
emojiOnly: false,
|
|
36
|
+
operatorName: null,
|
|
37
|
+
agentOverrides: {},
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('parses stored settings correctly', () => {
|
|
42
|
+
store['clawport-settings'] = JSON.stringify({
|
|
43
|
+
accentColor: '#3B82F6',
|
|
44
|
+
portalName: 'HQ',
|
|
45
|
+
portalSubtitle: 'Base',
|
|
46
|
+
portalEmoji: '🚀',
|
|
47
|
+
portalIcon: 'data:image/jpeg;base64,icon123',
|
|
48
|
+
agentOverrides: { jarvis: { emoji: '🎯' } },
|
|
49
|
+
})
|
|
50
|
+
const s = loadSettings()
|
|
51
|
+
expect(s.accentColor).toBe('#3B82F6')
|
|
52
|
+
expect(s.portalName).toBe('HQ')
|
|
53
|
+
expect(s.portalSubtitle).toBe('Base')
|
|
54
|
+
expect(s.portalEmoji).toBe('🚀')
|
|
55
|
+
expect(s.portalIcon).toBe('data:image/jpeg;base64,icon123')
|
|
56
|
+
expect(s.agentOverrides.jarvis).toEqual({ emoji: '🎯' })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns defaults for invalid JSON', () => {
|
|
60
|
+
store['clawport-settings'] = 'not-json{{'
|
|
61
|
+
const s = loadSettings()
|
|
62
|
+
expect(s.accentColor).toBeNull()
|
|
63
|
+
expect(s.agentOverrides).toEqual({})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('handles partial/malformed data gracefully', () => {
|
|
67
|
+
store['clawport-settings'] = JSON.stringify({
|
|
68
|
+
accentColor: 42,
|
|
69
|
+
portalName: true,
|
|
70
|
+
agentOverrides: 'not-an-object',
|
|
71
|
+
})
|
|
72
|
+
const s = loadSettings()
|
|
73
|
+
expect(s.accentColor).toBeNull()
|
|
74
|
+
expect(s.portalName).toBeNull()
|
|
75
|
+
expect(s.portalEmoji).toBeNull()
|
|
76
|
+
expect(s.portalIcon).toBeNull()
|
|
77
|
+
expect(s.agentOverrides).toEqual({})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('saveSettings', () => {
|
|
82
|
+
it('persists settings to localStorage', () => {
|
|
83
|
+
const settings = {
|
|
84
|
+
accentColor: '#EF4444',
|
|
85
|
+
portalName: 'Test',
|
|
86
|
+
portalSubtitle: null,
|
|
87
|
+
portalEmoji: null,
|
|
88
|
+
portalIcon: null,
|
|
89
|
+
iconBgHidden: false,
|
|
90
|
+
emojiOnly: false,
|
|
91
|
+
operatorName: null,
|
|
92
|
+
agentOverrides: {},
|
|
93
|
+
}
|
|
94
|
+
saveSettings(settings)
|
|
95
|
+
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
96
|
+
'clawport-settings',
|
|
97
|
+
JSON.stringify(settings),
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('round-trips through load', () => {
|
|
102
|
+
const settings = {
|
|
103
|
+
accentColor: '#22C55E',
|
|
104
|
+
portalName: 'Green HQ',
|
|
105
|
+
portalSubtitle: 'Ops Center',
|
|
106
|
+
portalEmoji: '🏠',
|
|
107
|
+
portalIcon: 'data:image/png;base64,test',
|
|
108
|
+
iconBgHidden: false,
|
|
109
|
+
emojiOnly: false,
|
|
110
|
+
operatorName: null,
|
|
111
|
+
agentOverrides: {
|
|
112
|
+
vera: { emoji: '🧙', profileImage: 'data:image/jpeg;base64,abc' },
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
saveSettings(settings)
|
|
116
|
+
const loaded = loadSettings()
|
|
117
|
+
expect(loaded).toEqual(settings)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('hexToAccentFill', () => {
|
|
122
|
+
it('converts gold hex to rgba at 0.15 alpha', () => {
|
|
123
|
+
expect(hexToAccentFill('#F5C518')).toBe('rgba(245,197,24,0.15)')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('converts blue hex correctly', () => {
|
|
127
|
+
expect(hexToAccentFill('#3B82F6')).toBe('rgba(59,130,246,0.15)')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('converts black hex correctly', () => {
|
|
131
|
+
expect(hexToAccentFill('#000000')).toBe('rgba(0,0,0,0.15)')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('converts white hex correctly', () => {
|
|
135
|
+
expect(hexToAccentFill('#FFFFFF')).toBe('rgba(255,255,255,0.15)')
|
|
136
|
+
})
|
|
137
|
+
})
|
package/lib/settings.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Settings types + localStorage helpers for ClawPort
|
|
2
|
+
|
|
3
|
+
export interface AgentOverride {
|
|
4
|
+
emoji?: string
|
|
5
|
+
profileImage?: string // base64 data URL
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ClawPortSettings {
|
|
9
|
+
accentColor: string | null
|
|
10
|
+
portalName: string | null
|
|
11
|
+
portalSubtitle: string | null
|
|
12
|
+
portalEmoji: string | null
|
|
13
|
+
portalIcon: string | null // base64 data URL for custom icon image
|
|
14
|
+
iconBgHidden: boolean // hide colored background on sidebar logo
|
|
15
|
+
emojiOnly: boolean // show emoji avatars without colored background
|
|
16
|
+
operatorName: string | null
|
|
17
|
+
agentOverrides: Record<string, AgentOverride>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STORAGE_KEY = 'clawport-settings'
|
|
21
|
+
const LEGACY_KEY = 'manor-settings'
|
|
22
|
+
|
|
23
|
+
export const DEFAULTS: ClawPortSettings = {
|
|
24
|
+
accentColor: null,
|
|
25
|
+
portalName: null,
|
|
26
|
+
portalSubtitle: null,
|
|
27
|
+
portalEmoji: null,
|
|
28
|
+
portalIcon: null,
|
|
29
|
+
iconBgHidden: false,
|
|
30
|
+
emojiOnly: false,
|
|
31
|
+
operatorName: null,
|
|
32
|
+
agentOverrides: {},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadSettings(): ClawPortSettings {
|
|
36
|
+
if (typeof window === 'undefined') return { ...DEFAULTS }
|
|
37
|
+
try {
|
|
38
|
+
let raw = localStorage.getItem(STORAGE_KEY)
|
|
39
|
+
// Migrate from legacy key
|
|
40
|
+
if (!raw) {
|
|
41
|
+
raw = localStorage.getItem(LEGACY_KEY)
|
|
42
|
+
if (raw) {
|
|
43
|
+
localStorage.setItem(STORAGE_KEY, raw)
|
|
44
|
+
localStorage.removeItem(LEGACY_KEY)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!raw) return { ...DEFAULTS }
|
|
48
|
+
const parsed = JSON.parse(raw)
|
|
49
|
+
return {
|
|
50
|
+
accentColor: typeof parsed.accentColor === 'string' ? parsed.accentColor : null,
|
|
51
|
+
portalName: typeof parsed.portalName === 'string' ? parsed.portalName : typeof parsed.manorName === 'string' ? parsed.manorName : null,
|
|
52
|
+
portalSubtitle: typeof parsed.portalSubtitle === 'string' ? parsed.portalSubtitle : typeof parsed.manorSubtitle === 'string' ? parsed.manorSubtitle : null,
|
|
53
|
+
portalEmoji: typeof parsed.portalEmoji === 'string' ? parsed.portalEmoji : typeof parsed.manorEmoji === 'string' ? parsed.manorEmoji : null,
|
|
54
|
+
portalIcon: typeof parsed.portalIcon === 'string' ? parsed.portalIcon : typeof parsed.manorIcon === 'string' ? parsed.manorIcon : null,
|
|
55
|
+
iconBgHidden: typeof parsed.iconBgHidden === 'boolean' ? parsed.iconBgHidden : false,
|
|
56
|
+
emojiOnly: typeof parsed.emojiOnly === 'boolean' ? parsed.emojiOnly : false,
|
|
57
|
+
operatorName: typeof parsed.operatorName === 'string' ? parsed.operatorName : null,
|
|
58
|
+
agentOverrides:
|
|
59
|
+
parsed.agentOverrides && typeof parsed.agentOverrides === 'object'
|
|
60
|
+
? parsed.agentOverrides
|
|
61
|
+
: {},
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
return { ...DEFAULTS }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveSettings(settings: ClawPortSettings): void {
|
|
69
|
+
if (typeof window === 'undefined') return
|
|
70
|
+
try {
|
|
71
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Convert hex color to rgba at 0.15 alpha for accent-fill backgrounds */
|
|
76
|
+
export function hexToAccentFill(hex: string): string {
|
|
77
|
+
const r = parseInt(hex.slice(1, 3), 16)
|
|
78
|
+
const g = parseInt(hex.slice(3, 5), 16)
|
|
79
|
+
const b = parseInt(hex.slice(5, 7), 16)
|
|
80
|
+
return `rgba(${r},${g},${b},0.15)`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Return '#fff' or '#000' depending on which has better contrast against the given hex color */
|
|
84
|
+
export function hexToContrastText(hex: string): string {
|
|
85
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255
|
|
86
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255
|
|
87
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255
|
|
88
|
+
// sRGB luminance (WCAG 2.0)
|
|
89
|
+
const lum =
|
|
90
|
+
0.2126 * (r <= 0.03928 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4) +
|
|
91
|
+
0.7152 * (g <= 0.03928 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4) +
|
|
92
|
+
0.0722 * (b <= 0.03928 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4)
|
|
93
|
+
return lum > 0.4 ? '#000' : '#fff'
|
|
94
|
+
}
|
package/lib/styles.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Semantic style objects for TypeScript components
|
|
2
|
+
// Use when CSS classes aren't practical (dynamic styles)
|
|
3
|
+
|
|
4
|
+
export const typography = {
|
|
5
|
+
largeTitle: { fontSize: 'var(--text-large-title)', fontWeight: 'var(--weight-bold)', letterSpacing: 'var(--tracking-tight)', lineHeight: 'var(--leading-tight)' },
|
|
6
|
+
title1: { fontSize: 'var(--text-title1)', fontWeight: 'var(--weight-bold)', letterSpacing: 'var(--tracking-tight)', lineHeight: 'var(--leading-tight)' },
|
|
7
|
+
title2: { fontSize: 'var(--text-title2)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-normal)', lineHeight: 'var(--leading-snug)' },
|
|
8
|
+
title3: { fontSize: 'var(--text-title3)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-normal)', lineHeight: 'var(--leading-snug)' },
|
|
9
|
+
body: { fontSize: 'var(--text-body)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
|
|
10
|
+
subheadline: { fontSize: 'var(--text-subheadline)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
|
|
11
|
+
footnote: { fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
|
|
12
|
+
caption1: { fontSize: 'var(--text-caption1)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
|
|
13
|
+
caption2: { fontSize: 'var(--text-caption2)', fontWeight: 'var(--weight-regular)', lineHeight: 'var(--leading-normal)' },
|
|
14
|
+
sectionHeader: { fontSize: 'var(--text-caption2)', fontWeight: 'var(--weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' as const, color: 'var(--text-tertiary)' },
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
export const layout = {
|
|
18
|
+
sidebarWidth: 220,
|
|
19
|
+
detailPanelWidth: 360,
|
|
20
|
+
chatSidebarWidth: 300,
|
|
21
|
+
memorySidebarWidth: 260,
|
|
22
|
+
maxContentWidth: 1200,
|
|
23
|
+
headerHeight: 52,
|
|
24
|
+
} as const
|
package/lib/themes.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ThemeId = 'dark' | 'glass' | 'color' | 'light' | 'system';
|
|
2
|
+
|
|
3
|
+
export const THEMES: { id: ThemeId; label: string; emoji: string }[] = [
|
|
4
|
+
{ id: 'dark', label: 'Dark', emoji: '🌑' },
|
|
5
|
+
{ id: 'glass', label: 'Glass', emoji: '🪟' },
|
|
6
|
+
{ id: 'color', label: 'Color', emoji: '🎨' },
|
|
7
|
+
{ id: 'light', label: 'Light', emoji: '☀️' },
|
|
8
|
+
{ id: 'system', label: 'System', emoji: '⚙️' },
|
|
9
|
+
];
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
3
|
+
import { transcribeViaApi, transcribe } from './transcribe'
|
|
4
|
+
|
|
5
|
+
// --- transcribeViaApi ---
|
|
6
|
+
|
|
7
|
+
describe('transcribeViaApi', () => {
|
|
8
|
+
const originalFetch = globalThis.fetch
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
globalThis.fetch = vi.fn()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
globalThis.fetch = originalFetch
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns transcript text on successful API response', async () => {
|
|
19
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
20
|
+
ok: true,
|
|
21
|
+
json: async () => ({ text: 'hello world' }),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const blob = new Blob(['fake audio'], { type: 'audio/webm' })
|
|
25
|
+
const result = await transcribeViaApi(blob)
|
|
26
|
+
expect(result).toBe('hello world')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('calls /api/transcribe with POST and FormData', async () => {
|
|
30
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
31
|
+
ok: true,
|
|
32
|
+
json: async () => ({ text: 'test' }),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
36
|
+
await transcribeViaApi(blob)
|
|
37
|
+
|
|
38
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
39
|
+
'/api/transcribe',
|
|
40
|
+
expect.objectContaining({ method: 'POST' })
|
|
41
|
+
)
|
|
42
|
+
const callArgs = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
43
|
+
expect(callArgs[1].body).toBeInstanceOf(FormData)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns null when API returns non-ok response', async () => {
|
|
47
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
48
|
+
ok: false,
|
|
49
|
+
status: 500,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
53
|
+
const result = await transcribeViaApi(blob)
|
|
54
|
+
expect(result).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns null when API returns empty text', async () => {
|
|
58
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
59
|
+
ok: true,
|
|
60
|
+
json: async () => ({ text: '' }),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
64
|
+
const result = await transcribeViaApi(blob)
|
|
65
|
+
expect(result).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns null when API response has no text field', async () => {
|
|
69
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
70
|
+
ok: true,
|
|
71
|
+
json: async () => ({ error: 'no transcript' }),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
75
|
+
const result = await transcribeViaApi(blob)
|
|
76
|
+
expect(result).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns null on network error', async () => {
|
|
80
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'))
|
|
81
|
+
|
|
82
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
83
|
+
const result = await transcribeViaApi(blob)
|
|
84
|
+
expect(result).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// --- transcribe (orchestrator) ---
|
|
89
|
+
|
|
90
|
+
describe('transcribe', () => {
|
|
91
|
+
const originalFetch = globalThis.fetch
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
globalThis.fetch = vi.fn()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
globalThis.fetch = originalFetch
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('returns whisper source when API transcription succeeds', async () => {
|
|
102
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
103
|
+
ok: true,
|
|
104
|
+
json: async () => ({ text: 'transcribed text' }),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
108
|
+
const result = await transcribe(blob)
|
|
109
|
+
expect(result).toEqual({ text: 'transcribed text', source: 'whisper' })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns failed source when API transcription fails', async () => {
|
|
113
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
114
|
+
ok: false,
|
|
115
|
+
status: 500,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
119
|
+
const result = await transcribe(blob)
|
|
120
|
+
expect(result).toEqual({ text: null, source: 'failed' })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('returns failed source on network error', async () => {
|
|
124
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'))
|
|
125
|
+
|
|
126
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
127
|
+
const result = await transcribe(blob)
|
|
128
|
+
expect(result).toEqual({ text: null, source: 'failed' })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('returns failed source when API returns empty text', async () => {
|
|
132
|
+
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
133
|
+
ok: true,
|
|
134
|
+
json: async () => ({ text: '' }),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const blob = new Blob(['audio'], { type: 'audio/webm' })
|
|
138
|
+
const result = await transcribe(blob)
|
|
139
|
+
expect(result).toEqual({ text: null, source: 'failed' })
|
|
140
|
+
})
|
|
141
|
+
})
|