Skip to content

Commit e5a9606

Browse files
MarkShawn2020claude
andcommitted
feat: MaaS registry management + events page + workspace consolidation
- Add MaaS provider registry (4 new Tauri commands: get/save/upsert/delete) - New /settings/maas page with MaasRegistryView for managing model providers - New /events page - Extract LLM provider presets to constants (LLM_PROVIDER_PRESETS) - Consolidate Home view into WorkspaceView (remove standalone Home) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4f311f5 commit e5a9606

22 files changed

Lines changed: 839 additions & 341 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,134 @@ fn save_provider_contexts(contexts: &serde_json::Map<String, Value>) -> Result<(
369369
Ok(())
370370
}
371371

372+
// ============================================================================
373+
// MaaS Registry (provider + model mappings for empty-state cascading picker)
374+
// ============================================================================
375+
376+
#[derive(Debug, Clone, Serialize, Deserialize)]
377+
#[serde(rename_all = "camelCase")]
378+
struct MaasModel {
379+
id: String,
380+
display_name: String,
381+
model_name: String,
382+
}
383+
384+
#[derive(Debug, Clone, Serialize, Deserialize)]
385+
#[serde(rename_all = "camelCase")]
386+
struct MaasProvider {
387+
key: String,
388+
label: String,
389+
base_url: String,
390+
auth_env_key: String,
391+
models: Vec<MaasModel>,
392+
}
393+
394+
fn get_maas_registry_path() -> PathBuf {
395+
get_lovstudio_dir().join("maas_registry.json")
396+
}
397+
398+
fn default_maas_registry() -> Vec<MaasProvider> {
399+
fn m(id: &str, display: &str, name: &str) -> MaasModel {
400+
MaasModel {
401+
id: id.to_string(),
402+
display_name: display.to_string(),
403+
model_name: name.to_string(),
404+
}
405+
}
406+
let anthropic_native_models = vec![
407+
m("opus-4-7", "Claude Opus 4.7", "claude-opus-4-7-20251101"),
408+
m("sonnet-4-6", "Claude Sonnet 4.6", "claude-sonnet-4-6-20251001"),
409+
m("haiku-4-5", "Claude Haiku 4.5", "claude-haiku-4-5-20250930"),
410+
];
411+
vec![
412+
MaasProvider {
413+
key: "anthropic-subscription".into(),
414+
label: "Anthropic Subscription".into(),
415+
base_url: "".into(),
416+
auth_env_key: "CLAUDE_CODE_USE_OAUTH".into(),
417+
models: anthropic_native_models.clone(),
418+
},
419+
MaasProvider {
420+
key: "native".into(),
421+
label: "Anthropic API".into(),
422+
base_url: "https://api.anthropic.com".into(),
423+
auth_env_key: "ANTHROPIC_API_KEY".into(),
424+
models: anthropic_native_models,
425+
},
426+
MaasProvider {
427+
key: "zenmux".into(),
428+
label: "ZenMux".into(),
429+
base_url: "https://zenmux.ai/api/anthropic".into(),
430+
auth_env_key: "ZENMUX_API_KEY".into(),
431+
models: vec![
432+
m("sonnet-4-6", "Claude Sonnet 4.6", "anthropic/claude-sonnet-4-6-20251001"),
433+
m("sonnet-4-5", "Claude Sonnet 4.5", "anthropic/claude-sonnet-4.5"),
434+
m("haiku-4-5", "Claude Haiku 4.5", "anthropic/claude-haiku-4.5"),
435+
],
436+
},
437+
MaasProvider {
438+
key: "modelgate".into(),
439+
label: "ModelGate".into(),
440+
base_url: "https://mg.aid.pub/claude-proxy".into(),
441+
auth_env_key: "MODELGATE_API_KEY".into(),
442+
models: vec![
443+
m("sonnet-4-6", "Claude Sonnet 4.6", "anthropic/claude-sonnet-4-6-20251001"),
444+
m("sonnet-4-5", "Claude Sonnet 4.5", "anthropic/claude-sonnet-4.5"),
445+
m("haiku-4-5", "Claude Haiku 4.5", "anthropic/claude-haiku-4.5"),
446+
],
447+
},
448+
MaasProvider {
449+
key: "qiniu".into(),
450+
label: "Qiniu Cloud".into(),
451+
base_url: "https://api.qnaigc.com".into(),
452+
auth_env_key: "QINIU_API_KEY".into(),
453+
models: vec![
454+
m("sonnet-4-6", "Claude Sonnet 4.6", "claude-sonnet-4-6-20251001"),
455+
m("haiku-4-5", "Claude Haiku 4.5", "claude-haiku-4-5-20250930"),
456+
],
457+
},
458+
MaasProvider {
459+
key: "siliconflow".into(),
460+
label: "SiliconFlow".into(),
461+
base_url: "https://api.siliconflow.com/v1".into(),
462+
auth_env_key: "SILICONFLOW_API_KEY".into(),
463+
models: vec![
464+
m("sonnet-4-5", "Claude Sonnet 4.5", "claude-sonnet-4-5"),
465+
m("haiku-4-5", "Claude Haiku 4.5", "claude-haiku-4-5"),
466+
],
467+
},
468+
MaasProvider {
469+
key: "univibe".into(),
470+
label: "UniVibe".into(),
471+
base_url: "https://api.univibe.cc/anthropic".into(),
472+
auth_env_key: "UNIVIBE_API_KEY".into(),
473+
models: vec![
474+
m("sonnet-4-6", "Claude Sonnet 4.6", "claude-sonnet-4-6-20251001"),
475+
m("haiku-4-5", "Claude Haiku 4.5", "claude-haiku-4-5-20250930"),
476+
],
477+
},
478+
]
479+
}
480+
481+
fn load_maas_registry() -> Result<Vec<MaasProvider>, String> {
482+
let path = get_maas_registry_path();
483+
if !path.exists() {
484+
return Ok(default_maas_registry());
485+
}
486+
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
487+
serde_json::from_str(&content).map_err(|e| e.to_string())
488+
}
489+
490+
fn persist_maas_registry(registry: &[MaasProvider]) -> Result<(), String> {
491+
let path = get_maas_registry_path();
492+
if let Some(parent) = path.parent() {
493+
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
494+
}
495+
let output = serde_json::to_string_pretty(registry).map_err(|e| e.to_string())?;
496+
fs::write(&path, output).map_err(|e| e.to_string())?;
497+
Ok(())
498+
}
499+
372500
/// Get path to ~/.claude.json (MCP servers config)
373501
fn get_claude_json_path() -> PathBuf {
374502
dirs::home_dir().unwrap().join(".claude.json")
@@ -5560,6 +5688,39 @@ fn snapshot_provider_context(
55605688
Ok(())
55615689
}
55625690

5691+
// ============================================================================
5692+
// MaaS Registry Commands
5693+
// ============================================================================
5694+
5695+
#[tauri::command]
5696+
fn get_maas_registry() -> Result<Vec<MaasProvider>, String> {
5697+
load_maas_registry()
5698+
}
5699+
5700+
#[tauri::command]
5701+
fn save_maas_registry(registry: Vec<MaasProvider>) -> Result<(), String> {
5702+
persist_maas_registry(&registry)
5703+
}
5704+
5705+
#[tauri::command]
5706+
fn upsert_maas_provider(provider: MaasProvider) -> Result<Vec<MaasProvider>, String> {
5707+
let mut registry = load_maas_registry()?;
5708+
match registry.iter().position(|p| p.key == provider.key) {
5709+
Some(idx) => registry[idx] = provider,
5710+
None => registry.push(provider),
5711+
}
5712+
persist_maas_registry(&registry)?;
5713+
Ok(registry)
5714+
}
5715+
5716+
#[tauri::command]
5717+
fn delete_maas_provider(key: String) -> Result<Vec<MaasProvider>, String> {
5718+
let mut registry = load_maas_registry()?;
5719+
registry.retain(|p| p.key != key);
5720+
persist_maas_registry(&registry)?;
5721+
Ok(registry)
5722+
}
5723+
55635724
// ============================================================================
55645725
// Settings Field Update Commands
55655726
// ============================================================================
@@ -7903,6 +8064,39 @@ pub fn run() {
79038064
}
79048065
});
79058066

8067+
// Start watching ~/.claude/projects/ for session changes (new/updated jsonl files)
8068+
let app_handle = app.handle().clone();
8069+
std::thread::spawn(move || {
8070+
let projects_dir = get_claude_dir().join("projects");
8071+
if !projects_dir.exists() {
8072+
let _ = fs::create_dir_all(&projects_dir);
8073+
}
8074+
8075+
let (tx, rx) = channel();
8076+
let mut watcher: RecommendedWatcher = match notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
8077+
if let Ok(event) = res {
8078+
if event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove() {
8079+
let _ = tx.send(());
8080+
}
8081+
}
8082+
}) {
8083+
Ok(w) => w,
8084+
Err(_) => return,
8085+
};
8086+
8087+
if watcher.watch(&projects_dir, RecursiveMode::Recursive).is_err() {
8088+
return;
8089+
}
8090+
8091+
loop {
8092+
if rx.recv().is_ok() {
8093+
// Debounce burst of writes from jsonl appends
8094+
while rx.recv_timeout(Duration::from_millis(500)).is_ok() {}
8095+
let _ = app_handle.emit("sessions-changed", ());
8096+
}
8097+
}
8098+
});
8099+
79068100
let settings = MenuItemBuilder::with_id("settings", "Settings...")
79078101
.accelerator("CmdOrCtrl+,")
79088102
.build(app)?;
@@ -8076,6 +8270,10 @@ pub fn run() {
80768270
get_provider_contexts,
80778271
set_provider_context_env,
80788272
snapshot_provider_context,
8273+
get_maas_registry,
8274+
save_maas_registry,
8275+
upsert_maas_provider,
8276+
delete_maas_provider,
80798277
update_settings_field,
80808278
update_settings_permission_field,
80818279
add_permission_directory,

src/App.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { AppConfigContext, useAppConfig, type AppConfig } from "./context";
2121
import { useUrlInit } from "./hooks";
2222
// Modular views
2323
import {
24-
Home,
2524
WorkspaceView,
2625
FeaturesView,
2726
FeaturesLayout,
@@ -45,6 +44,7 @@ import {
4544
SettingsView,
4645
EnvSettingsView,
4746
LlmProviderView,
47+
MaasRegistryView,
4848
ClaudeVersionView,
4949
ContextFilesView,
5050
ProjectList,
@@ -153,6 +153,8 @@ function App() {
153153
? "basic-env"
154154
: view.type === "basic-llm"
155155
? "basic-llm"
156+
: view.type === "basic-maas"
157+
? "basic-maas"
156158
: view.type === "basic-version"
157159
? "basic-version"
158160
: view.type === "basic-context"
@@ -196,6 +198,9 @@ function App() {
196198
case "basic-llm":
197199
navigate({ type: "basic-llm" });
198200
break;
201+
case "basic-maas":
202+
navigate({ type: "basic-maas" });
203+
break;
199204
case "basic-version":
200205
navigate({ type: "basic-version" });
201206
break;
@@ -252,7 +257,6 @@ function App() {
252257
canGoForward={canGoForward}
253258
onGoBack={goBack}
254259
onGoForward={goForward}
255-
onNavigate={navigate}
256260
onFeatureClick={handleFeatureClick}
257261
onShowProfileDialog={() => setShowProfileDialog(true)}
258262
onShowSettings={() => setShowSettings(true)}
@@ -261,15 +265,6 @@ function App() {
261265
{/* Vertical Feature Tabs Sidebar */}
262266
{featureTabsLayout === "vertical" && workspace && <VerticalFeatureTabs />}
263267
<main className="flex-1 overflow-auto">
264-
{view.type === "home" && (
265-
<Home
266-
onFeatureClick={handleFeatureClick}
267-
onProjectClick={(p) => navigate({ type: "chat-sessions", projectId: p.id, projectPath: p.path })}
268-
onSessionClick={(s) => navigate({ type: "chat-messages", projectId: s.project_id, projectPath: s.project_path || '', sessionId: s.id, summary: s.summary })}
269-
onSearch={() => navigate({ type: "chat-projects" })}
270-
onOpenAnnualReport={() => navigate({ type: "annual-report-2025" })}
271-
/>
272-
)}
273268
{view.type === "annual-report-2025" && (
274269
<AnnualReport2025 onClose={() => navigate({ type: "home" })} />
275270
)}
@@ -299,14 +294,15 @@ function App() {
299294
onBack={() => navigate({ type: "chat-sessions", projectId: view.projectId, projectPath: view.projectPath })}
300295
/>
301296
)}
302-
{(view.type === "basic-env" || view.type === "basic-llm" || view.type === "basic-version" || view.type === "basic-context" ||
297+
{(view.type === "basic-env" || view.type === "basic-llm" || view.type === "basic-maas" || view.type === "basic-version" || view.type === "basic-context" ||
303298
view.type === "settings" || view.type === "commands" || view.type === "command-detail" || view.type === "mcp" ||
304299
view.type === "skills" || view.type === "hooks" ||
305300
view.type === "sub-agents" || view.type === "sub-agent-detail" || view.type === "output-styles" ||
306301
view.type === "statusline" || view.type === "feature-template-detail") && (
307302
<FeaturesLayout currentFeature={currentFeature} onFeatureClick={handleFeatureClick}>
308303
{view.type === "basic-env" && <EnvSettingsView />}
309304
{view.type === "basic-llm" && <LlmProviderView />}
305+
{view.type === "basic-maas" && <MaasRegistryView />}
310306
{view.type === "basic-version" && <ClaudeVersionView />}
311307
{view.type === "basic-context" && <ContextFilesView />}
312308
{view.type === "settings" && (

src/components/GlobalHeader/GlobalHeader.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@ import { motion, AnimatePresence } from "framer-motion";
44
import {
55
PersonIcon, ChevronLeftIcon, ChevronRightIcon,
66
RocketIcon, CounterClockwiseClockIcon, BookmarkIcon, LayersIcon,
7+
CalendarIcon,
78
} from "@radix-ui/react-icons";
89
import { Avatar, AvatarImage, AvatarFallback } from "../ui/avatar";
910
import { Popover, PopoverTrigger, PopoverContent } from "../ui/popover";
1011
import { sidebarCollapsedAtom, profileAtom, workspaceDataAtom, primaryFeatureAtom, featureTabsLayoutAtom } from "@/store";
1112
import { GlobalFeatureTabs } from "./GlobalFeatureTabs";
12-
import type { View, FeatureType } from "@/types";
13+
import type { FeatureType } from "@/types";
1314

1415
interface GlobalHeaderProps {
1516
currentFeature: FeatureType | null;
1617
canGoBack: boolean;
1718
canGoForward: boolean;
1819
onGoBack: () => void;
1920
onGoForward: () => void;
20-
onNavigate: (view: View) => void;
2121
onFeatureClick: (feature: FeatureType) => void;
2222
onShowProfileDialog: () => void;
2323
onShowSettings: () => void;
@@ -29,7 +29,6 @@ export function GlobalHeader({
2929
canGoForward,
3030
onGoBack,
3131
onGoForward,
32-
onNavigate,
3332
onFeatureClick,
3433
onShowProfileDialog,
3534
onShowSettings,
@@ -44,7 +43,7 @@ export function GlobalHeader({
4443
const showFeatureTabs = !!workspace && featureTabsLayout === "horizontal";
4544

4645
// Main nav features - use primaryFeature for active state (not affected by profile menu clicks)
47-
const mainNavFeatures = ["workspace", "chat", "kb-distill", "kb-reference"] as const;
46+
const mainNavFeatures = ["workspace", "chat", "kb-distill", "kb-reference", "events"] as const;
4847
const isMainNavFeature = (f: string | null) => f && (mainNavFeatures.includes(f as typeof mainNavFeatures[number]) || f.startsWith("kb-"));
4948

5049
// Handle main nav click - updates primaryFeature
@@ -87,12 +86,6 @@ export function GlobalHeader({
8786
</div>
8887
{/* Center: menu group */}
8988
<div className="flex-1 flex items-center justify-center gap-0.5" data-tauri-drag-region>
90-
<NavButton
91-
isActive={primaryFeature === null}
92-
onClick={() => { setPrimaryFeature(null); onNavigate({ type: "home" }); }}
93-
icon={<img src="/logo.svg" alt="Lovcode" className="w-4 h-4" />}
94-
label="Lovcode"
95-
/>
9689
<NavButton
9790
isActive={primaryFeature === "workspace"}
9891
onClick={() => handleMainNavClick("workspace")}
@@ -117,6 +110,12 @@ export function GlobalHeader({
117110
icon={<BookmarkIcon className="w-4 h-4" />}
118111
label="Knowledge"
119112
/>
113+
<NavButton
114+
isActive={primaryFeature === "events"}
115+
onClick={() => handleMainNavClick("events")}
116+
icon={<CalendarIcon className="w-4 h-4" />}
117+
label="Events"
118+
/>
120119
{showFeatureTabs && (
121120
<>
122121
<div className="h-4 border-l border-border mx-2" />

0 commit comments

Comments
 (0)