@nextclaw/ui 0.5.47 → 0.6.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/assets/ChannelsList-BWQYaOuz.js +1 -0
  3. package/dist/assets/ChatPage-DsIuF-TC.js +32 -0
  4. package/dist/assets/DocBrowser-D4pXQDKt.js +1 -0
  5. package/dist/assets/MarketplacePage-Cj1HGbGe.js +49 -0
  6. package/dist/assets/ModelConfig-C2f3h7yq.js +1 -0
  7. package/dist/assets/{ProvidersList-D3hfY5U7.js → ProvidersList-DUdQEMNV.js} +1 -1
  8. package/dist/assets/RuntimeConfig-BnR60m9J.js +1 -0
  9. package/dist/assets/{SecretsConfig-BFDeNvwV.js → SecretsConfig-CXV017VN.js} +2 -2
  10. package/dist/assets/SessionsConfig-DsgHhuYe.js +2 -0
  11. package/dist/assets/{card-BREZdIEb.js → card-B7d3Z9Y7.js} +1 -1
  12. package/dist/assets/index-Dp6x_DHf.js +2 -0
  13. package/dist/assets/index-DsQL2mtx.css +1 -0
  14. package/dist/assets/{label-CzMB2yjV.js → label-Dlq0AZXx.js} +1 -1
  15. package/dist/assets/{logos-vVtRUuoo.js → logos-CSTJsbua.js} +1 -1
  16. package/dist/assets/{page-layout-B07kdurB.js → page-layout-DeBYaT_B.js} +1 -1
  17. package/dist/assets/provider-models-y4mUDcGF.js +1 -0
  18. package/dist/assets/{switch-Cr6cemeT.js → switch-DwDE9PLr.js} +1 -1
  19. package/dist/assets/{tabs-custom-BzcvgsvR.js → tabs-custom-DqY_ht59.js} +1 -1
  20. package/dist/assets/useConfig-BiM-oO9i.js +6 -0
  21. package/dist/assets/{useConfirmDialog-Dc5WHCUf.js → useConfirmDialog-BEFIWczY.js} +2 -2
  22. package/dist/assets/{vendor-Dh04PGww.js → vendor-Ylg6Wdt_.js} +84 -69
  23. package/dist/index.html +3 -3
  24. package/package.json +2 -1
  25. package/src/App.tsx +10 -6
  26. package/src/api/config.ts +42 -1
  27. package/src/api/types.ts +29 -0
  28. package/src/components/chat/ChatConversationPanel.tsx +75 -86
  29. package/src/components/chat/ChatInputBar.tsx +226 -0
  30. package/src/components/chat/ChatPage.tsx +359 -188
  31. package/src/components/chat/ChatSidebar.tsx +242 -0
  32. package/src/components/chat/ChatThread.tsx +92 -25
  33. package/src/components/chat/ChatWelcome.tsx +61 -0
  34. package/src/components/chat/SkillsPicker.tsx +137 -0
  35. package/src/components/chat/useChatStreamController.ts +287 -56
  36. package/src/components/config/ChannelForm.tsx +1 -1
  37. package/src/components/config/ChannelsList.tsx +3 -3
  38. package/src/components/config/ModelConfig.tsx +11 -89
  39. package/src/components/config/RuntimeConfig.tsx +29 -1
  40. package/src/components/layout/AppLayout.tsx +42 -6
  41. package/src/components/layout/Sidebar.tsx +72 -24
  42. package/src/components/marketplace/MarketplacePage.tsx +13 -3
  43. package/src/components/ui/popover.tsx +31 -0
  44. package/src/hooks/useConfig.ts +18 -0
  45. package/src/lib/i18n.ts +48 -0
  46. package/src/lib/provider-models.ts +129 -0
  47. package/dist/assets/ChannelsList-B6N0kXyK.js +0 -1
  48. package/dist/assets/ChatPage-DsDFvVQX.js +0 -32
  49. package/dist/assets/CronConfig-Cbz6V8MU.js +0 -1
  50. package/dist/assets/DocBrowser-hQzP4Iai.js +0 -1
  51. package/dist/assets/MarketplacePage-DMoWoU1y.js +0 -49
  52. package/dist/assets/ModelConfig-BXjF-qbA.js +0 -1
  53. package/dist/assets/RuntimeConfig-DJ7qIejp.js +0 -1
  54. package/dist/assets/SessionsConfig-CJF7lPkX.js +0 -2
  55. package/dist/assets/index-C5cdRzpO.css +0 -1
  56. package/dist/assets/index-uTbQ-MAY.js +0 -2
  57. package/dist/assets/useConfig-B4Y6cGwc.js +0 -6
@@ -28,6 +28,7 @@ function createEmptyAgent(): AgentProfileView {
28
28
  default: false,
29
29
  workspace: '',
30
30
  model: '',
31
+ engine: '',
31
32
  contextTokens: undefined,
32
33
  maxToolIterations: undefined
33
34
  };
@@ -62,6 +63,7 @@ export function RuntimeConfig() {
62
63
  const [dmScope, setDmScope] = useState<DmScope>('per-channel-peer');
63
64
  const [maxPingPongTurns, setMaxPingPongTurns] = useState(0);
64
65
  const [defaultContextTokens, setDefaultContextTokens] = useState(200000);
66
+ const [defaultEngine, setDefaultEngine] = useState('native');
65
67
 
66
68
  useEffect(() => {
67
69
  if (!config) {
@@ -73,6 +75,7 @@ export function RuntimeConfig() {
73
75
  default: Boolean(agent.default),
74
76
  workspace: agent.workspace ?? '',
75
77
  model: agent.model ?? '',
78
+ engine: agent.engine ?? '',
76
79
  contextTokens: agent.contextTokens,
77
80
  maxToolIterations: agent.maxToolIterations
78
81
  }))
@@ -95,13 +98,16 @@ export function RuntimeConfig() {
95
98
  setDmScope((config.session?.dmScope as DmScope) ?? 'per-channel-peer');
96
99
  setMaxPingPongTurns(config.session?.agentToAgent?.maxPingPongTurns ?? 0);
97
100
  setDefaultContextTokens(config.agents.defaults.contextTokens ?? 200000);
101
+ setDefaultEngine(config.agents.defaults.engine ?? 'native');
98
102
  }, [config]);
99
103
 
100
104
  const uiHints = schema?.uiHints;
101
105
  const dmScopeHint = hintForPath('session.dmScope', uiHints);
102
106
  const maxPingHint = hintForPath('session.agentToAgent.maxPingPongTurns', uiHints);
103
107
  const defaultContextTokensHint = hintForPath('agents.defaults.contextTokens', uiHints);
108
+ const defaultEngineHint = hintForPath('agents.defaults.engine', uiHints);
104
109
  const agentContextTokensHint = hintForPath('agents.list.*.contextTokens', uiHints);
110
+ const agentEngineHint = hintForPath('agents.list.*.engine', uiHints);
105
111
  const agentsHint = hintForPath('agents.list', uiHints);
106
112
  const bindingsHint = hintForPath('bindings', uiHints);
107
113
 
@@ -141,6 +147,9 @@ export function RuntimeConfig() {
141
147
  if (agent.model?.trim()) {
142
148
  normalized.model = agent.model.trim();
143
149
  }
150
+ if (agent.engine?.trim()) {
151
+ normalized.engine = agent.engine.trim();
152
+ }
144
153
  if (typeof agent.contextTokens === 'number') {
145
154
  normalized.contextTokens = Math.max(1000, agent.contextTokens);
146
155
  }
@@ -203,7 +212,8 @@ export function RuntimeConfig() {
203
212
  data: {
204
213
  agents: {
205
214
  defaults: {
206
- contextTokens: Math.max(1000, defaultContextTokens)
215
+ contextTokens: Math.max(1000, defaultContextTokens),
216
+ engine: defaultEngine.trim() || 'native'
207
217
  },
208
218
  list: normalizedAgents
209
219
  },
@@ -251,6 +261,19 @@ export function RuntimeConfig() {
251
261
  {defaultContextTokensHint?.help ?? t('defaultContextTokensHelp')}
252
262
  </p>
253
263
  </div>
264
+ <div className="space-y-2">
265
+ <label className="text-sm font-medium text-gray-800">
266
+ {defaultEngineHint?.label ?? t('defaultEngine')}
267
+ </label>
268
+ <Input
269
+ value={defaultEngine}
270
+ onChange={(event) => setDefaultEngine(event.target.value)}
271
+ placeholder={t('defaultEnginePlaceholder')}
272
+ />
273
+ <p className="text-xs text-gray-500">
274
+ {defaultEngineHint?.help ?? t('defaultEngineHelp')}
275
+ </p>
276
+ </div>
254
277
  <div className="space-y-2">
255
278
  <label className="text-sm font-medium text-gray-800">{dmScopeHint?.label ?? t('dmScope')}</label>
256
279
  <Select value={dmScope} onValueChange={(v) => setDmScope(v as DmScope)}>
@@ -308,6 +331,11 @@ export function RuntimeConfig() {
308
331
  onChange={(event) => updateAgent(index, { model: event.target.value })}
309
332
  placeholder={t('modelOverridePlaceholder')}
310
333
  />
334
+ <Input
335
+ value={agent.engine ?? ''}
336
+ onChange={(event) => updateAgent(index, { engine: event.target.value })}
337
+ placeholder={agentEngineHint?.label ?? t('engineOverridePlaceholder')}
338
+ />
311
339
  <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
312
340
  <Input
313
341
  type="number"
@@ -1,7 +1,9 @@
1
1
  import { lazy, Suspense } from 'react';
2
+ import { useLocation } from 'react-router-dom';
2
3
  import { Sidebar } from './Sidebar';
3
4
  import { DocBrowserProvider, useDocBrowser } from '@/components/doc-browser/DocBrowserContext';
4
5
  import { useDocLinkInterceptor } from '@/components/doc-browser/useDocLinkInterceptor';
6
+ import { cn } from '@/lib/utils';
5
7
 
6
8
  const DocBrowser = lazy(async () => ({ default: (await import('@/components/doc-browser/DocBrowser')).DocBrowser }));
7
9
 
@@ -9,20 +11,54 @@ interface AppLayoutProps {
9
11
  children: React.ReactNode;
10
12
  }
11
13
 
14
+ function isMainWorkspaceRoute(pathname: string): boolean {
15
+ const normalized = pathname.toLowerCase();
16
+ return (
17
+ normalized === '/chat' ||
18
+ normalized.startsWith('/chat/') ||
19
+ normalized === '/skills' ||
20
+ normalized.startsWith('/skills/') ||
21
+ normalized === '/cron' ||
22
+ normalized.startsWith('/cron/')
23
+ );
24
+ }
25
+
26
+ function isChannelsRoute(pathname: string): boolean {
27
+ const normalized = pathname.toLowerCase();
28
+ return normalized === '/channels' || normalized.startsWith('/channels/');
29
+ }
30
+
12
31
  function AppLayoutInner({ children }: AppLayoutProps) {
13
32
  const { isOpen, mode } = useDocBrowser();
14
33
  useDocLinkInterceptor();
34
+ const { pathname } = useLocation();
35
+ const isMainRoute = isMainWorkspaceRoute(pathname);
36
+ const lockPageScroll = isChannelsRoute(pathname);
15
37
 
16
38
  return (
17
39
  <div className="h-screen flex bg-background font-sans text-foreground">
18
- <Sidebar />
40
+ {!isMainRoute && <Sidebar mode="settings" />}
19
41
  <div className="flex-1 flex min-w-0 overflow-hidden relative">
20
42
  <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
21
- <main className="flex-1 overflow-auto custom-scrollbar p-8">
22
- <div className="max-w-6xl mx-auto animate-fade-in h-full">
23
- {children}
24
- </div>
25
- </main>
43
+ {isMainRoute ? (
44
+ <div className="flex-1 h-full overflow-hidden">{children}</div>
45
+ ) : (
46
+ <main
47
+ className={cn(
48
+ 'flex-1 custom-scrollbar p-8',
49
+ lockPageScroll ? 'overflow-auto xl:overflow-hidden' : 'overflow-auto'
50
+ )}
51
+ >
52
+ <div
53
+ className={cn(
54
+ 'max-w-6xl mx-auto animate-fade-in h-full',
55
+ lockPageScroll && 'min-h-0 xl:overflow-hidden'
56
+ )}
57
+ >
58
+ {children}
59
+ </div>
60
+ </main>
61
+ )}
26
62
  </div>
27
63
  {/* Doc Browser: docked mode renders inline, floating mode renders as overlay */}
28
64
  {isOpen && mode === 'docked' && (
@@ -1,14 +1,20 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
3
3
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
4
- import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound } from 'lucide-react';
4
+ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft } from 'lucide-react';
5
5
  import { NavLink } from 'react-router-dom';
6
6
  import { useDocBrowser } from '@/components/doc-browser';
7
7
  import { useI18n } from '@/components/providers/I18nProvider';
8
8
  import { useTheme } from '@/components/providers/ThemeProvider';
9
9
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
10
10
 
11
- export function Sidebar() {
11
+ type SidebarMode = 'main' | 'settings';
12
+
13
+ type SidebarProps = {
14
+ mode: SidebarMode;
15
+ };
16
+
17
+ export function Sidebar({ mode }: SidebarProps) {
12
18
  const docBrowser = useDocBrowser();
13
19
  const { language, setLanguage } = useI18n();
14
20
  const { theme, setTheme } = useTheme();
@@ -30,12 +36,26 @@ export function Sidebar() {
30
36
  setTheme(nextTheme);
31
37
  };
32
38
 
33
- const navItems = [
39
+ // Core navigation items - primary features
40
+ const mainNavItems = [
34
41
  {
35
42
  target: '/chat',
36
43
  label: t('chat'),
37
44
  icon: MessageCircle,
38
45
  },
46
+ {
47
+ target: '/chat/cron',
48
+ label: t('cron'),
49
+ icon: AlarmClock,
50
+ },
51
+ {
52
+ target: '/chat/skills',
53
+ label: t('marketplaceFilterSkills'),
54
+ icon: BrainCircuit,
55
+ }
56
+ ];
57
+
58
+ const settingsNavItems = [
39
59
  {
40
60
  target: '/model',
41
61
  label: t('model'),
@@ -61,11 +81,6 @@ export function Sidebar() {
61
81
  label: t('sessions'),
62
82
  icon: History,
63
83
  },
64
- {
65
- target: '/cron',
66
- label: t('cron'),
67
- icon: AlarmClock,
68
- },
69
84
  {
70
85
  target: '/secrets',
71
86
  label: t('secrets'),
@@ -75,28 +90,41 @@ export function Sidebar() {
75
90
  target: '/marketplace/plugins',
76
91
  label: t('marketplaceFilterPlugins'),
77
92
  icon: Plug,
78
- },
79
- {
80
- target: '/marketplace/skills',
81
- label: t('marketplaceFilterSkills'),
82
- icon: BrainCircuit,
83
93
  }
84
94
  ];
95
+ const navItems = mode === 'main' ? mainNavItems : settingsNavItems;
85
96
 
86
97
  return (
87
98
  <aside className="w-[240px] shrink-0 flex flex-col h-full py-6 px-4 bg-secondary">
88
- {/* Logo Area */}
89
- <div className="px-2 mb-8">
90
- <div className="flex items-center gap-2.5 cursor-pointer">
91
- <div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
92
- <img src="/logo.svg" alt="NextClaw" className="h-full w-full object-contain" />
99
+ {mode === 'settings' ? (
100
+ <div className="px-2 mb-6">
101
+ <NavLink
102
+ to="/chat"
103
+ className="group inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900"
104
+ >
105
+ <ArrowLeft className="h-3.5 w-3.5 text-gray-500 group-hover:text-gray-800" />
106
+ <span>{t('backToMain')}</span>
107
+ </NavLink>
108
+ <div className="mt-5 px-1">
109
+ <div className="flex items-center gap-2.5">
110
+ <Settings className="h-5 w-5 text-gray-700" />
111
+ <h1 className="text-[28px] leading-none font-semibold tracking-[-0.02em] text-gray-900">{t('settings')}</h1>
112
+ </div>
93
113
  </div>
94
- <span className="text-[15px] font-semibold text-gray-800 tracking-[-0.01em]">NextClaw</span>
95
114
  </div>
96
- </div>
115
+ ) : (
116
+ <div className="px-2 mb-8">
117
+ <div className="flex items-center gap-2.5 cursor-pointer">
118
+ <div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
119
+ <img src="/logo.svg" alt="NextClaw" className="h-full w-full object-contain" />
120
+ </div>
121
+ <span className="text-[15px] font-semibold text-gray-800 tracking-[-0.01em]">NextClaw</span>
122
+ </div>
123
+ </div>
124
+ )}
97
125
 
98
126
  {/* Navigation */}
99
- <nav className="flex-1">
127
+ <nav className="flex-1 flex flex-col">
100
128
  <ul className="space-y-1">
101
129
  {navItems.map((item) => {
102
130
  const Icon = item.icon;
@@ -123,13 +151,33 @@ export function Sidebar() {
123
151
  )}
124
152
  </NavLink>
125
153
  </li>
126
- );
127
- })}
128
- </ul>
154
+ );
155
+ })}
156
+ </ul>
129
157
  </nav>
130
158
 
131
159
  {/* Help Button */}
132
160
  <div className="pt-3 border-t border-[#dde0ea] mt-3">
161
+ {mode === 'main' && (
162
+ <div className="mb-2">
163
+ <NavLink
164
+ to="/settings"
165
+ className={({ isActive }) => cn(
166
+ 'group w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-[14px] font-medium transition-all duration-base',
167
+ isActive
168
+ ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
169
+ : 'text-gray-600 hover:bg-[#e4e7ef] hover:text-gray-900'
170
+ )}
171
+ >
172
+ {({ isActive }) => (
173
+ <>
174
+ <Settings className={cn('h-[17px] w-[17px] transition-colors', isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800')} />
175
+ <span className="flex-1 text-left">{t('settings')}</span>
176
+ </>
177
+ )}
178
+ </NavLink>
179
+ </div>
180
+ )}
133
181
  <div className="mb-2">
134
182
  <Select value={theme} onValueChange={(value) => handleThemeSwitch(value as UiTheme)}>
135
183
  <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2.5 text-[14px] font-medium text-gray-600 hover:bg-[#e4e7ef] focus:ring-0">
@@ -50,6 +50,9 @@ type InstalledRenderEntry = {
50
50
  };
51
51
 
52
52
  type MarketplaceRouteType = 'plugins' | 'skills';
53
+ type MarketplacePageProps = {
54
+ forcedType?: MarketplaceRouteType;
55
+ };
53
56
 
54
57
  function normalizeMarketplaceKey(value: string | undefined): string {
55
58
  return (value ?? '').trim().toLowerCase();
@@ -506,24 +509,31 @@ function PaginationBar(props: {
506
509
  );
507
510
  }
508
511
 
509
- export function MarketplacePage() {
512
+ export function MarketplacePage(props: MarketplacePageProps = {}) {
510
513
  const navigate = useNavigate();
511
514
  const params = useParams<{ type?: string }>();
512
515
  const { language } = useI18n();
513
516
  const docBrowser = useDocBrowser();
517
+ const forcedType = props.forcedType;
514
518
 
515
519
  const routeType: MarketplaceRouteType | null = useMemo(() => {
520
+ if (forcedType === 'plugins' || forcedType === 'skills') {
521
+ return forcedType;
522
+ }
516
523
  if (params.type === 'plugins' || params.type === 'skills') {
517
524
  return params.type;
518
525
  }
519
526
  return null;
520
- }, [params.type]);
527
+ }, [forcedType, params.type]);
521
528
 
522
529
  useEffect(() => {
530
+ if (forcedType) {
531
+ return;
532
+ }
523
533
  if (!routeType) {
524
534
  navigate('/marketplace/plugins', { replace: true });
525
535
  }
526
- }, [routeType, navigate]);
536
+ }, [forcedType, routeType, navigate]);
527
537
 
528
538
  const typeFilter: MarketplaceItemType = routeType === 'skills' ? 'skill' : 'plugin';
529
539
  const localeFallbacks = useMemo(() => buildLocaleFallbacks(language), [language]);
@@ -0,0 +1,31 @@
1
+ import * as React from 'react';
2
+ import * as PopoverPrimitive from '@radix-ui/react-popover';
3
+
4
+ import { cn } from '@/lib/utils';
5
+
6
+ const Popover = PopoverPrimitive.Root;
7
+
8
+ const PopoverTrigger = PopoverPrimitive.Trigger;
9
+
10
+ const PopoverAnchor = PopoverPrimitive.Anchor;
11
+
12
+ const PopoverContent = React.forwardRef<
13
+ React.ElementRef<typeof PopoverPrimitive.Content>,
14
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
15
+ >(({ className, sideOffset = 8, align = 'start', ...props }, ref) => (
16
+ <PopoverPrimitive.Portal>
17
+ <PopoverPrimitive.Content
18
+ ref={ref}
19
+ sideOffset={sideOffset}
20
+ align={align}
21
+ className={cn(
22
+ 'z-[var(--z-popover,50)] w-72 overflow-hidden rounded-2xl border border-gray-200/50 bg-white p-4 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
23
+ className
24
+ )}
25
+ {...props}
26
+ />
27
+ </PopoverPrimitive.Portal>
28
+ ));
29
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30
+
31
+ export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
@@ -17,6 +17,7 @@ import {
17
17
  updateSession,
18
18
  deleteSession,
19
19
  sendChatTurn,
20
+ fetchChatCapabilities,
20
21
  fetchCronJobs,
21
22
  deleteCronJob,
22
23
  setCronJobEnabled,
@@ -242,6 +243,23 @@ export function useSendChatTurn() {
242
243
  });
243
244
  }
244
245
 
246
+ export function useChatCapabilities(params?: { sessionKey?: string | null; agentId?: string | null }) {
247
+ const sessionKey = params?.sessionKey?.trim() || undefined;
248
+ const agentId = params?.agentId?.trim() || undefined;
249
+ return useQuery({
250
+ queryKey: ['chat-capabilities', sessionKey ?? null, agentId ?? null],
251
+ queryFn: async () => {
252
+ try {
253
+ return await fetchChatCapabilities({ sessionKey, agentId });
254
+ } catch {
255
+ return { stopSupported: false };
256
+ }
257
+ },
258
+ staleTime: 10_000,
259
+ retry: false
260
+ });
261
+ }
262
+
245
263
  export function useCronJobs(params: { all?: boolean } = { all: true }) {
246
264
  return useQuery({
247
265
  queryKey: ['cron', params],
package/src/lib/i18n.ts CHANGED
@@ -131,6 +131,9 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
131
131
  secrets: { zh: '密钥管理', en: 'Secrets' },
132
132
  runtime: { zh: '路由与运行时', en: 'Routing & Runtime' },
133
133
  marketplace: { zh: '市场', en: 'Marketplace' },
134
+ advanced: { zh: '高级', en: 'Advanced' },
135
+ settings: { zh: '设置', en: 'Settings' },
136
+ backToMain: { zh: '返回主界面', en: 'Back to Main' },
134
137
 
135
138
  // Common
136
139
  enabled: { zh: '启用', en: 'Enabled' },
@@ -359,6 +362,8 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
359
362
  dmScopeHelp: { zh: '控制私聊会话如何隔离。', en: 'Control how direct-message sessions are isolated.' },
360
363
  defaultContextTokens: { zh: '默认上下文 Token', en: 'Default Context Tokens' },
361
364
  defaultContextTokensHelp: { zh: '当 Agent 未设置单独值时使用该上下文预算。', en: 'Input context budget for agents when no per-agent override is set.' },
365
+ defaultEngine: { zh: '默认引擎', en: 'Default Engine' },
366
+ defaultEngineHelp: { zh: '默认使用的 Agent 引擎类型,例如 native 或 codex-sdk。', en: 'Default agent engine kind, for example native or codex-sdk.' },
362
367
  maxPingPongTurns: { zh: '最大乒乓轮次', en: 'Max Ping-Pong Turns' },
363
368
  maxPingPongTurnsHelp: { zh: '设为 0 可阻止 Agent 间自动 ping-pong。', en: 'Set to 0 to block automatic agent-to-agent ping-pong loops.' },
364
369
  agentList: { zh: 'Agent 列表', en: 'Agent List' },
@@ -374,6 +379,8 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
374
379
  agentIdPlaceholder: { zh: 'Agent ID(例如 engineer)', en: 'Agent ID (e.g. engineer)' },
375
380
  workspaceOverridePlaceholder: { zh: '工作空间覆盖(可选)', en: 'Workspace override (optional)' },
376
381
  modelOverridePlaceholder: { zh: '模型覆盖(可选)', en: 'Model override (optional)' },
382
+ defaultEnginePlaceholder: { zh: '默认引擎(如 native 或 codex-sdk)', en: 'Default engine (e.g. native or codex-sdk)' },
383
+ engineOverridePlaceholder: { zh: '引擎覆盖(可选)', en: 'Engine override (optional)' },
377
384
  contextTokensPlaceholder: { zh: '上下文 tokens', en: 'Context tokens' },
378
385
  maxToolsPlaceholder: { zh: '最大工具次数', en: 'Max tools' },
379
386
  defaultAgent: { zh: '默认 Agent', en: 'Default agent' },
@@ -478,6 +485,9 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
478
485
  chatSearchSessionPlaceholder: { zh: '搜索会话 key / 标签', en: 'Search session key / label' },
479
486
  chatAgentLabel: { zh: '目标 Agent', en: 'Target Agent' },
480
487
  chatSelectAgent: { zh: '选择 Agent', en: 'Select Agent' },
488
+ chatModelLabel: { zh: '对话模型', en: 'Chat Model' },
489
+ chatSelectModel: { zh: '选择模型', en: 'Select model' },
490
+ chatModelNoOptions: { zh: '暂无可用模型,请先配置 Provider。', en: 'No available models. Configure a provider first.' },
481
491
  chatSessionLabel: { zh: '当前会话', en: 'Current Session' },
482
492
  chatNoSession: { zh: '未选择会话', en: 'No session selected' },
483
493
  chatNoSessionHint: { zh: '创建一个会话并发送第一条消息。', en: 'Create a session and send your first message.' },
@@ -487,6 +497,9 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
487
497
  chatInputPlaceholder: { zh: '输入消息,Enter 发送,Shift + Enter 换行', en: 'Type a message, Enter to send, Shift + Enter for newline' },
488
498
  chatInputHint: { zh: '支持多轮上下文,默认走当前会话。', en: 'Multi-turn context is preserved in the current session.' },
489
499
  chatSend: { zh: '发送', en: 'Send' },
500
+ chatStop: { zh: '停止', en: 'Stop' },
501
+ chatStopPreparing: { zh: '正在建立可停止会话,请稍候…', en: 'Preparing stoppable run…' },
502
+ chatStopUnavailable: { zh: '当前后端引擎不支持手动停止。', en: 'Manual stop is not supported by the current backend engine.' },
490
503
  chatSending: { zh: '发送中...', en: 'Sending...' },
491
504
  chatQueueSend: { zh: '排队发送', en: 'Queue' },
492
505
  chatQueuedHintPrefix: { zh: '当前有', en: 'Queued' },
@@ -501,10 +514,45 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
501
514
  chatRoleMessage: { zh: '消息', en: 'Message' },
502
515
  chatToolCall: { zh: '工具调用', en: 'Tool Call' },
503
516
  chatToolResult: { zh: '工具结果', en: 'Tool Result' },
517
+ chatToolWorkflow: { zh: '工具工作流', en: 'Tool Workflow' },
518
+ chatToolWorkflowDetails: { zh: '展开查看参数和结果', en: 'Expand to view params and results' },
504
519
  chatToolOutput: { zh: '查看输出', en: 'View Output' },
505
520
  chatToolNoOutput: { zh: '无输出(执行完成)', en: 'No output (completed)' },
506
521
  chatReasoning: { zh: '查看推理内容', en: 'Show reasoning' },
507
522
 
523
+ // Chat Sidebar (unified)
524
+ chatSidebarNewTask: { zh: '新任务', en: 'New Task' },
525
+ chatSidebarSearchPlaceholder: { zh: '搜索对话...', en: 'Search conversations...' },
526
+ chatSidebarScheduledTasks: { zh: '定时任务', en: 'Scheduled Tasks' },
527
+ chatSidebarSkills: { zh: '技能', en: 'Skills' },
528
+ chatSidebarTaskRecords: { zh: '会话记录', en: 'Sessions' },
529
+ chatSidebarToday: { zh: '今天', en: 'Today' },
530
+ chatSidebarYesterday: { zh: '昨天', en: 'Yesterday' },
531
+ chatSidebarPrevious7Days: { zh: '近 7 天', en: 'Previous 7 Days' },
532
+ chatSidebarOlder: { zh: '更早', en: 'Older' },
533
+
534
+ // Welcome page
535
+ chatWelcomeTitle: { zh: '你好,有什么可以帮你的吗?', en: 'Hello, how can I help you?' },
536
+ chatWelcomeSubtitle: { zh: '开始一个新任务或选择已有对话', en: 'Start a new task or select an existing conversation' },
537
+ chatWelcomeCapability1Title: { zh: '智能对话', en: 'Smart Conversations' },
538
+ chatWelcomeCapability1Desc: { zh: '多轮上下文对话,支持多种 AI 模型', en: 'Multi-turn context conversations with multiple AI models' },
539
+ chatWelcomeCapability2Title: { zh: '技能扩展', en: 'Skill Extensions' },
540
+ chatWelcomeCapability2Desc: { zh: '通过安装技能扩展 Agent 能力', en: 'Extend Agent capabilities by installing skills' },
541
+ chatWelcomeCapability3Title: { zh: '定时任务', en: 'Scheduled Tasks' },
542
+ chatWelcomeCapability3Desc: { zh: '设置定时执行的自动化任务', en: 'Set up scheduled automated tasks' },
543
+
544
+ // Skills picker
545
+ chatSkillsPickerTitle: { zh: '技能', en: 'Skills' },
546
+ chatSkillsPickerEmpty: { zh: '暂无已安装技能', en: 'No skills installed' },
547
+ chatSkillsPickerSearchPlaceholder: { zh: '搜索技能', en: 'Search skills' },
548
+ chatSkillsPickerNoDescription: { zh: '暂无描述', en: 'No description' },
549
+ chatSkillsPickerOfficial: { zh: '官方', en: 'Official' },
550
+ chatSkillsPickerManage: { zh: '管理技能', en: 'Manage Skills' },
551
+
552
+ // Input bar
553
+ chatInputAttach: { zh: '添加附件', en: 'Attach file' },
554
+ chatInputAttachComingSoon: { zh: '即将支持', en: 'Coming soon' },
555
+
508
556
  // Cron
509
557
  cronPageTitle: { zh: '定时任务', en: 'Cron Jobs' },
510
558
  cronPageDescription: { zh: '查看与删除定时任务,关注执行时间与状态。', en: 'View and delete cron jobs, track schedule and status.' },
@@ -0,0 +1,129 @@
1
+ import type { ConfigMetaView, ConfigView, ProviderConfigView } from '@/api/types';
2
+
3
+ export type ProviderModelCatalogItem = {
4
+ name: string;
5
+ displayName: string;
6
+ prefix: string;
7
+ aliases: string[];
8
+ models: string[];
9
+ configured: boolean;
10
+ };
11
+
12
+ export function normalizeStringList(input: string[] | null | undefined): string[] {
13
+ if (!input || input.length === 0) {
14
+ return [];
15
+ }
16
+ const deduped = new Set<string>();
17
+ for (const item of input) {
18
+ const trimmed = item.trim();
19
+ if (trimmed) {
20
+ deduped.add(trimmed);
21
+ }
22
+ }
23
+ return [...deduped];
24
+ }
25
+
26
+ export function stripProviderPrefix(model: string, prefix: string): string {
27
+ const trimmed = model.trim();
28
+ const cleanPrefix = prefix.trim();
29
+ if (!trimmed || !cleanPrefix) {
30
+ return trimmed;
31
+ }
32
+ const withSlash = `${cleanPrefix}/`;
33
+ if (trimmed.startsWith(withSlash)) {
34
+ return trimmed.slice(withSlash.length);
35
+ }
36
+ return trimmed;
37
+ }
38
+
39
+ export function toProviderLocalModel(model: string, aliases: string[]): string {
40
+ let normalized = model.trim();
41
+ if (!normalized) {
42
+ return '';
43
+ }
44
+ for (const alias of aliases) {
45
+ normalized = stripProviderPrefix(normalized, alias);
46
+ }
47
+ return normalized.trim();
48
+ }
49
+
50
+ export function composeProviderModel(prefix: string, localModel: string): string {
51
+ const normalizedModel = localModel.trim();
52
+ const normalizedPrefix = prefix.trim();
53
+ if (!normalizedModel) {
54
+ return '';
55
+ }
56
+ if (!normalizedPrefix) {
57
+ return normalizedModel;
58
+ }
59
+ return `${normalizedPrefix}/${normalizedModel}`;
60
+ }
61
+
62
+ export function findProviderByModel(
63
+ model: string,
64
+ providerCatalog: Array<{ name: string; aliases: string[] }>
65
+ ): string | null {
66
+ const trimmed = model.trim();
67
+ if (!trimmed) {
68
+ return null;
69
+ }
70
+ let bestMatch: { name: string; score: number } | null = null;
71
+ for (const provider of providerCatalog) {
72
+ for (const alias of provider.aliases) {
73
+ const cleanAlias = alias.trim();
74
+ if (!cleanAlias) {
75
+ continue;
76
+ }
77
+ if (trimmed === cleanAlias || trimmed.startsWith(`${cleanAlias}/`)) {
78
+ if (!bestMatch || cleanAlias.length > bestMatch.score) {
79
+ bestMatch = { name: provider.name, score: cleanAlias.length };
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return bestMatch?.name ?? null;
85
+ }
86
+
87
+ function isProviderConfigured(provider: ProviderConfigView | undefined): boolean {
88
+ if (!provider) {
89
+ return false;
90
+ }
91
+ // Keep in sync with ProvidersList "已配置" tab: only apiKeySet counts as configured.
92
+ return provider.apiKeySet === true;
93
+ }
94
+
95
+ export function buildProviderModelCatalog(params: {
96
+ meta?: ConfigMetaView;
97
+ config?: ConfigView;
98
+ onlyConfigured?: boolean;
99
+ }): ProviderModelCatalogItem[] {
100
+ const { meta, config, onlyConfigured = false } = params;
101
+
102
+ const catalog = (meta?.providers ?? []).map((spec) => {
103
+ const providerConfig = config?.providers?.[spec.name];
104
+ const prefix = (spec.modelPrefix || spec.name || '').trim();
105
+ const aliases = normalizeStringList([spec.modelPrefix || '', spec.name || '']);
106
+ const defaultModels = normalizeStringList((spec.defaultModels ?? []).map((model) => toProviderLocalModel(model, aliases)));
107
+ const customModels = normalizeStringList(
108
+ (providerConfig?.models ?? []).map((model) => toProviderLocalModel(model, aliases))
109
+ );
110
+ const models = normalizeStringList([...defaultModels, ...customModels]);
111
+ const configDisplayName = providerConfig?.displayName?.trim();
112
+ const configured = isProviderConfigured(providerConfig);
113
+
114
+ return {
115
+ name: spec.name,
116
+ displayName: configDisplayName || spec.displayName || spec.name,
117
+ prefix,
118
+ aliases,
119
+ models,
120
+ configured
121
+ } satisfies ProviderModelCatalogItem;
122
+ });
123
+
124
+ if (!onlyConfigured) {
125
+ return catalog;
126
+ }
127
+
128
+ return catalog.filter((provider) => provider.configured && provider.models.length > 0);
129
+ }