@pheem49/mint 1.7.1 → 1.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.
Files changed (51) hide show
  1. package/Cargo.lock +3 -3
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/crates/mint-core/src/api_server.rs +62 -4
  5. package/crates/mint-core/src/chat.rs +79 -0
  6. package/crates/mint-core/src/lib.rs +1 -1
  7. package/crates/mint-core/src/workflows.rs +8 -0
  8. package/package.json +2 -1
  9. package/src/renderer/src/App.tsx +1 -1
  10. package/src/renderer/src/components/ChatPanel.tsx +27 -14
  11. package/src/renderer/src/components/DashboardSidebar.tsx +71 -12
  12. package/src/renderer/src/components/ImageStudioPanel.tsx +7 -3
  13. package/src/renderer/src/components/MintDashboard.tsx +199 -5
  14. package/src/renderer/src/components/PicturesLibrary.tsx +1 -3
  15. package/src/renderer/src/components/Settings/AudioTab.tsx +97 -0
  16. package/src/renderer/src/components/Settings/AutomationTab.tsx +195 -0
  17. package/src/renderer/src/components/Settings/GeneralTab.tsx +950 -0
  18. package/src/renderer/src/components/Settings/MemoryTab.tsx +60 -0
  19. package/src/renderer/src/components/Settings/PluginsTab.tsx +452 -0
  20. package/src/renderer/src/components/Settings/ShortcutsTab.tsx +30 -0
  21. package/src/renderer/src/components/Settings/ThemeTab.tsx +129 -0
  22. package/src/renderer/src/components/SettingsWindow.tsx +175 -962
  23. package/src/renderer/src/components/WorkflowBuilderPanel.tsx +536 -0
  24. package/src/renderer/src/components/WorkflowsWindow.tsx +46 -0
  25. package/src/renderer/src/css/image-studio.css +9 -4
  26. package/src/renderer/src/css/settings.css +75 -6
  27. package/src/renderer/src/css/styles.css +355 -38
  28. package/src/renderer/src/css/workflows.css +542 -0
  29. package/src/renderer/src/env.d.ts +2 -0
  30. package/src/renderer/src/index.css +1 -0
  31. package/src/renderer/src/tauri.ts +97 -0
  32. package/src/renderer/src-web/components/ChatPanel.tsx +27 -14
  33. package/src/renderer/src-web/components/DashboardSidebar.tsx +17 -1
  34. package/src/renderer/src-web/components/ImageStudioPanel.tsx +7 -3
  35. package/src/renderer/src-web/components/MintDashboard.tsx +189 -0
  36. package/src/renderer/src-web/components/PicturesLibrary.tsx +1 -3
  37. package/src/renderer/src-web/components/Settings/AudioTab.tsx +97 -0
  38. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +196 -0
  39. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +948 -0
  40. package/src/renderer/src-web/components/Settings/MemoryTab.tsx +60 -0
  41. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +452 -0
  42. package/src/renderer/src-web/components/Settings/ShortcutsTab.tsx +30 -0
  43. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +129 -0
  44. package/src/renderer/src-web/components/SettingsWindow.tsx +187 -968
  45. package/src/renderer/src-web/css/image-studio.css +9 -4
  46. package/src/renderer/src-web/css/settings.css +75 -6
  47. package/src/renderer/src-web/css/styles.css +294 -38
  48. package/src/renderer/src-web/env.d.ts +2 -0
  49. package/src/renderer/src-web/tauri.ts +36 -0
  50. package/src-tauri/src/lib.rs +83 -90
  51. package/src-tauri/tauri.conf.json +1 -1
package/Cargo.lock CHANGED
@@ -2095,7 +2095,7 @@ dependencies = [
2095
2095
 
2096
2096
  [[package]]
2097
2097
  name = "mint-cli"
2098
- version = "1.6.4"
2098
+ version = "1.8.0"
2099
2099
  dependencies = [
2100
2100
  "anyhow",
2101
2101
  "base64 0.22.1",
@@ -2113,7 +2113,7 @@ dependencies = [
2113
2113
 
2114
2114
  [[package]]
2115
2115
  name = "mint-core"
2116
- version = "1.6.4"
2116
+ version = "1.8.0"
2117
2117
  dependencies = [
2118
2118
  "base64 0.22.1",
2119
2119
  "chrono",
@@ -2134,7 +2134,7 @@ dependencies = [
2134
2134
 
2135
2135
  [[package]]
2136
2136
  name = "mint-desktop"
2137
- version = "1.6.4"
2137
+ version = "1.8.0"
2138
2138
  dependencies = [
2139
2139
  "base64 0.22.1",
2140
2140
  "dirs",
package/Cargo.toml CHANGED
@@ -7,7 +7,7 @@ members = [
7
7
  resolver = "2"
8
8
 
9
9
  [workspace.package]
10
- version = "1.7.1"
10
+ version = "1.8.0"
11
11
  edition = "2024"
12
12
  license = "AGPL-3.0-only"
13
13
 
package/README.md CHANGED
@@ -95,6 +95,15 @@ Mint is a local-first AI assistant running on your machine, capable of handling
95
95
  - Telegram, Discord Gateway, Discord RPC, Slack Socket Mode, LINE, and WhatsApp
96
96
  Cloud API integrations.
97
97
  - Signed Tauri update checks with an explicit approval step before installation.
98
+ - Dynamic local Ollama model fetching in the Settings Window to query and display the actual models installed on your machine.
99
+ - Pill-styled clean horizontal system event dividers for provider and model change notifications in the chat panel.
100
+
101
+ ## What's New in v1.8.0
102
+
103
+ - **Integrated Web Search Settings**: Easily toggle between **Brave Search API** and **Google Custom Search API** directly from the General settings tab.
104
+ - **Image Generation Providers**: Choose and configure credentials for Stability AI, Ideogram, Replicate, OpenAI DALL-E, and Google NanoBanana (Gemini Images) inside dedicated inline settings cards.
105
+ - **Inline Productivity Settings**: Set up Client IDs, secrets, and API keys for native Gmail, Google Calendar, and Notion integrations inline inside the General settings tab.
106
+ - **Revamped Plugins Layout**: Moved **Learned AI Skills** (equivalent to `mint learn` in CLI) to the top of the Plugins tab, followed by MCP Servers (External tools), and Built-in Plugins (Spotify and Discord RPC) at the bottom.
98
107
 
99
108
  ## <img src="assets/setup.svg" width="24" height="24" valign="middle" /> Prerequisites
100
109
 
@@ -272,6 +272,58 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
272
272
  .await;
273
273
  }
274
274
  }
275
+ ("POST", "/api/interactions") => {
276
+ #[derive(Deserialize)]
277
+ #[serde(rename_all = "camelCase")]
278
+ struct ApiSaveInteraction {
279
+ chat_id: String,
280
+ user_text: String,
281
+ provider: String,
282
+ model: String,
283
+ }
284
+
285
+ if let Ok(req) = serde_json::from_str::<ApiSaveInteraction>(body) {
286
+ if let Ok(memory) = MemoryStore::open_default() {
287
+ match memory.add_interaction_for_chat(
288
+ &req.chat_id,
289
+ &req.user_text,
290
+ "",
291
+ &req.provider,
292
+ &req.model,
293
+ ) {
294
+ Ok(row_id) => {
295
+ let res_json = json!({ "success": true, "id": row_id });
296
+ send_json_response(socket, "200 OK", &res_json.to_string())
297
+ .await;
298
+ }
299
+ Err(error) => {
300
+ let err_json =
301
+ json!({ "success": false, "message": error.to_string() });
302
+ send_json_response(
303
+ socket,
304
+ "500 Internal Server Error",
305
+ &err_json.to_string(),
306
+ )
307
+ .await;
308
+ }
309
+ }
310
+ } else {
311
+ send_json_response(
312
+ socket,
313
+ "500 Internal Server Error",
314
+ "{\"success\":false,\"message\":\"db error\"}",
315
+ )
316
+ .await;
317
+ }
318
+ } else {
319
+ send_json_response(
320
+ socket,
321
+ "400 Bad Request",
322
+ "{\"success\":false,\"message\":\"invalid body\"}",
323
+ )
324
+ .await;
325
+ }
326
+ }
275
327
  ("POST", "/api/interactions/agent-activity") => {
276
328
  #[derive(Deserialize)]
277
329
  #[serde(rename_all = "camelCase")]
@@ -414,19 +466,22 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
414
466
  chat_id: Option<String>,
415
467
  image_data_uri: Option<String>,
416
468
  audio_data_uri: Option<String>,
469
+ document_attachment: Option<crate::chat::DocumentAttachment>,
417
470
  }
418
471
 
419
472
  if let Ok(req) = serde_json::from_str::<ApiChatRequest>(body) {
420
473
  let config = load_config().unwrap_or_default();
421
- let mut chat_req = ChatRequest {
474
+ let chat_req = ChatRequest {
422
475
  message: req.message,
423
476
  system_instruction: req.system_instruction.unwrap_or_default(),
424
477
  chat_id: req.chat_id,
425
478
  image_data_uri: req.image_data_uri,
426
479
  audio_data_uri: req.audio_data_uri,
427
- document_attachment: None,
480
+ document_attachment: req.document_attachment,
428
481
  workspace_path: None,
429
482
  };
483
+ let mut chat_req =
484
+ chat_req.with_document_context(&config).unwrap_or(chat_req);
430
485
  let sent_image = chat_req.image_data_uri.clone();
431
486
  let sent_message = chat_req.message.clone();
432
487
 
@@ -495,19 +550,22 @@ pub async fn start_api_server(port: u16) -> Result<(), std::io::Error> {
495
550
  chat_id: Option<String>,
496
551
  image_data_uri: Option<String>,
497
552
  audio_data_uri: Option<String>,
553
+ document_attachment: Option<crate::chat::DocumentAttachment>,
498
554
  }
499
555
 
500
556
  if let Ok(req) = serde_json::from_str::<ApiChatRequest>(body) {
501
557
  let config = load_config().unwrap_or_default();
502
- let mut chat_req = ChatRequest {
558
+ let chat_req = ChatRequest {
503
559
  message: req.message,
504
560
  system_instruction: req.system_instruction.unwrap_or_default(),
505
561
  chat_id: req.chat_id,
506
562
  image_data_uri: req.image_data_uri,
507
563
  audio_data_uri: req.audio_data_uri,
508
- document_attachment: None,
564
+ document_attachment: req.document_attachment,
509
565
  workspace_path: None,
510
566
  };
567
+ let mut chat_req =
568
+ chat_req.with_document_context(&config).unwrap_or(chat_req);
511
569
  let sent_image = chat_req.image_data_uri.clone();
512
570
  let sent_message = chat_req.message.clone();
513
571
 
@@ -108,6 +108,85 @@ pub struct DocumentAttachment {
108
108
  pub data_uri: String,
109
109
  }
110
110
 
111
+ const MAX_DOCUMENT_BYTES: usize = 10 * 1024 * 1024;
112
+ const MAX_DOCUMENT_CONTEXT_CHARS: usize = 400_000;
113
+ static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
114
+
115
+ impl ChatRequest {
116
+ pub fn with_document_context(&self, config: &crate::MintConfig) -> Result<ChatRequest, String> {
117
+ let Some(document) = &self.document_attachment else {
118
+ return Ok(self.clone());
119
+ };
120
+
121
+ let (mime_type, encoded) = document
122
+ .data_uri
123
+ .strip_prefix("data:")
124
+ .and_then(|payload| payload.split_once(";base64,"))
125
+ .ok_or_else(|| "invalid PDF attachment data URI".to_string())?;
126
+
127
+ if !mime_type.eq_ignore_ascii_case("application/pdf") {
128
+ return Err("only PDF document attachments are supported".into());
129
+ }
130
+
131
+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
132
+ let bytes = BASE64_STANDARD
133
+ .decode(encoded)
134
+ .map_err(|error| format!("invalid PDF attachment encoding: {error}"))?;
135
+ if bytes.len() > MAX_DOCUMENT_BYTES {
136
+ return Err("PDF attachment is too large (> 10 MiB)".into());
137
+ }
138
+
139
+ let directory = crate::config::config_path()
140
+ .map_err(|error| error.to_string())?
141
+ .with_file_name("Attachments");
142
+ std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
143
+
144
+ let safe_name: String = document
145
+ .filename
146
+ .chars()
147
+ .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '.' | '-' | '_'))
148
+ .take(80)
149
+ .collect();
150
+ let safe_name = if safe_name.is_empty() {
151
+ "document".into()
152
+ } else {
153
+ safe_name
154
+ };
155
+
156
+ let path = directory.join(format!(
157
+ "mint-pdf-{}-{}.pdf",
158
+ COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
159
+ safe_name
160
+ ));
161
+ std::fs::write(&path, bytes).map_err(|error| error.to_string())?;
162
+
163
+ let extracted = crate::knowledge::extract_document_text(&path, config)
164
+ .map_err(|error| error.to_string());
165
+ let _ = std::fs::remove_file(&path);
166
+ let extracted = extracted?;
167
+
168
+ let mut context: String = extracted.chars().take(MAX_DOCUMENT_CONTEXT_CHARS).collect();
169
+ if extracted.chars().count() > MAX_DOCUMENT_CONTEXT_CHARS {
170
+ context.push_str("\n\n[PDF text truncated because it is long.]");
171
+ }
172
+
173
+ let filename = document.filename.trim();
174
+ let filename = if filename.is_empty() {
175
+ "attached.pdf"
176
+ } else {
177
+ filename
178
+ };
179
+
180
+ let mut enriched = self.clone();
181
+ enriched.document_attachment = None;
182
+ enriched.message = format!(
183
+ "{}\n\nAttached PDF: {filename}\nUse the extracted PDF text below when answering. If the user asks for a summary, summarize this document.\n\n--- Extracted PDF text ---\n{context}\n--- End extracted PDF text ---",
184
+ self.message
185
+ );
186
+ Ok(enriched)
187
+ }
188
+ }
189
+
111
190
  #[derive(Debug, Clone, Serialize)]
112
191
  #[serde(rename_all = "camelCase")]
113
192
  pub struct ChatResponse {
@@ -78,7 +78,7 @@ pub use symbols::{CodeSymbol, SymbolError, SymbolIndex, build_symbol_index};
78
78
  pub use tasks::{Task, TaskError, TaskStore, tasks_path};
79
79
  pub use tts::{TtsUrl, google_tts_urls};
80
80
  pub use weather::{WeatherError, WeatherReport, weather};
81
- pub use workflows::{WorkflowError, load_workflows, workflows_path};
81
+ pub use workflows::{WorkflowError, load_workflows, save_workflows, workflows_path};
82
82
  pub mod api_server;
83
83
  pub use api_server::start_api_server;
84
84
  pub mod channels;
@@ -79,3 +79,11 @@ fn save_default_workflows(path: &PathBuf) -> Result<(), WorkflowError> {
79
79
  source,
80
80
  })
81
81
  }
82
+
83
+ pub fn save_workflows(workflows: &[Value]) -> Result<(), WorkflowError> {
84
+ let path = workflows_path()?;
85
+ let directory = path.parent().ok_or(WorkflowError::MissingDirectory)?;
86
+ fs::create_dir_all(directory).map_err(WorkflowError::CreateDirectory)?;
87
+ let raw = serde_json::to_string_pretty(workflows).map_err(WorkflowError::Serialize)?;
88
+ fs::write(&path, format!("{raw}\n")).map_err(|source| WorkflowError::Write { path, source })
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pheem49/mint",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "A native Tauri desktop AI assistant with a Rust backend and React UI.",
5
5
  "scripts": {
6
6
  "dev:web": "vite --config vite.config.web.ts",
@@ -58,6 +58,7 @@
58
58
  "dependencies": {
59
59
  "@tauri-apps/api": "^2.0.0",
60
60
  "@vitejs/plugin-react": "^6.0.1",
61
+ "@xyflow/react": "^12.11.1",
61
62
  "material-icon-theme": "5.34.0",
62
63
  "pixi-live2d-display": "^0.4.0",
63
64
  "pixi.js": "^6.5.10",
@@ -6,7 +6,6 @@ const WidgetWindow = lazy(() => import('./components/WidgetWindow'))
6
6
  const ProactiveGlow = lazy(() => import('./components/ProactiveGlow'))
7
7
  const ScreenPicker = lazy(() => import('./components/ScreenPicker'))
8
8
  const MintDashboard = lazy(() => import('./components/MintDashboard'))
9
-
10
9
  export default function App() {
11
10
  const [hash, setHash] = useState(window.location.hash || '#/')
12
11
 
@@ -29,5 +28,6 @@ export default function App() {
29
28
  if (route.startsWith('/proactive-glow')) content = <ProactiveGlow />
30
29
  if (route.startsWith('/screen-picker')) content = <ScreenPicker />
31
30
 
31
+
32
32
  return <Suspense fallback={null}>{content}</Suspense>
33
33
  }
@@ -1702,21 +1702,33 @@ export default function ChatPanel({
1702
1702
  return (
1703
1703
  <section className={`conversation-panel ${isEmptyChat ? 'is-empty' : ''}`}>
1704
1704
  <div className="chat-container">
1705
- {interactions.map((interaction) => (
1706
- <div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1707
- {interaction.isSystemEvent ? (
1708
- <div className="system-event" style={{ background: 'rgba(16, 185, 129, 0.06)', border: '1px solid rgba(16, 185, 129, 0.2)', borderRadius: '8px', padding: '10px 14px', color: '#a7f3d0', fontSize: '0.82rem', lineHeight: '1.45', alignSelf: 'stretch' }}>
1709
- {interaction.userText}
1710
- </div>
1711
- ) : interaction.userText && (
1712
- <div className="message user-message">
1713
- <div className="bubble-wrapper">
1714
- <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
1715
- <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1705
+ {interactions.map((interaction) => {
1706
+ const isSystemEvent = interaction.provider === 'system' && interaction.model === 'provider_change';
1707
+ if (isSystemEvent) {
1708
+ return (
1709
+ <div key={interaction.id} className="system-event-divider">
1710
+ <div className="system-event-line" />
1711
+ <div className="system-event-pill">
1712
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
1713
+ <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
1714
+ </svg>
1715
+ <span>{interaction.userText}</span>
1716
1716
  </div>
1717
+ <div className="system-event-line" />
1717
1718
  </div>
1718
- )}
1719
- <div className="message ai-message">
1719
+ );
1720
+ }
1721
+ return (
1722
+ <div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1723
+ {interaction.userText && (
1724
+ <div className="message user-message">
1725
+ <div className="bubble-wrapper">
1726
+ <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
1727
+ <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1728
+ </div>
1729
+ </div>
1730
+ )}
1731
+ <div className="message ai-message">
1720
1732
  <div className="bubble-wrapper">
1721
1733
  {renderCompletedActivity(interaction)}
1722
1734
  {renderFileChanges(interaction)}
@@ -1750,7 +1762,8 @@ export default function ChatPanel({
1750
1762
  </div>
1751
1763
  </div>
1752
1764
  </div>
1753
- ))}
1765
+ );
1766
+ })}
1754
1767
 
1755
1768
  {sending && (
1756
1769
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
@@ -1,11 +1,13 @@
1
- import { useState } from 'react'
1
+ import { useState, useEffect, useRef } from 'react'
2
2
 
3
- export type DashboardView = 'chat' | 'pictures' | 'model' | 'workspeac' | 'imagine'
3
+ export type DashboardView = 'chat' | 'pictures' | 'model' | 'workspace' | 'imagine' | 'workflows'
4
4
 
5
5
  interface ChatSessionItem {
6
6
  id: string
7
7
  title: string
8
8
  kind: string
9
+ createdAt?: string
10
+ updatedAt?: string
9
11
  }
10
12
 
11
13
  interface DashboardSidebarProps {
@@ -33,6 +35,8 @@ interface DashboardSidebarProps {
33
35
  onSetInteractionEnabled: (enabled: boolean) => void
34
36
  onSetShowInteractionGuide: (visible: boolean) => void
35
37
  onShowToast: (message: string) => void
38
+ isSearchOpen: boolean
39
+ onSetSearchOpen: (open: boolean) => void
36
40
  }
37
41
 
38
42
  export default function DashboardSidebar({
@@ -60,9 +64,23 @@ export default function DashboardSidebar({
60
64
  onSetInteractionEnabled,
61
65
  onSetShowInteractionGuide,
62
66
  onShowToast,
67
+ isSearchOpen,
68
+ onSetSearchOpen,
63
69
  }: DashboardSidebarProps) {
64
70
  const [editingSessionId, setEditingSessionId] = useState<string | null>(null)
65
71
  const [editTitleValue, setEditTitleValue] = useState('')
72
+ const [isMoreOpen, setIsMoreOpen] = useState(false)
73
+ const moreContainerRef = useRef<HTMLDivElement>(null)
74
+
75
+ useEffect(() => {
76
+ function handleClickOutside(event: MouseEvent) {
77
+ if (moreContainerRef.current && !moreContainerRef.current.contains(event.target as Node)) {
78
+ setIsMoreOpen(false)
79
+ }
80
+ }
81
+ document.addEventListener('mousedown', handleClickOutside)
82
+ return () => document.removeEventListener('mousedown', handleClickOutside)
83
+ }, [])
66
84
 
67
85
  const handleSaveRename = (id: string) => {
68
86
  if (editTitleValue.trim() && editTitleValue.trim() !== chatSessions.find(s => s.id === id)?.title) {
@@ -115,6 +133,16 @@ export default function DashboardSidebar({
115
133
  <span>New Chat</span>
116
134
  </button>
117
135
 
136
+ <button className="sidebar-top-action sidebar-search-btn" onClick={() => onSetSearchOpen(true)}>
137
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
138
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
139
+ <circle cx="11" cy="11" r="8"></circle>
140
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
141
+ </svg>
142
+ </span>
143
+ <span>Search Chats</span>
144
+ </button>
145
+
118
146
  <button className={`sidebar-top-action ${view === 'chat' ? 'is-active' : ''}`} onClick={() => onSetView('chat')}>
119
147
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
120
148
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
@@ -133,22 +161,14 @@ export default function DashboardSidebar({
133
161
  </span>
134
162
  <span>Pictures</span>
135
163
  </button>
136
- <button className={`sidebar-top-action ${view === 'imagine' ? 'is-active' : ''}`} onClick={() => onSetView('imagine')}>
137
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
138
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
139
- <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
140
- </svg>
141
- </span>
142
- <span>Image Studio</span>
143
- </button>
144
- <button className={`sidebar-top-action ${view === 'workspeac' ? 'is-active' : ''}`} onClick={() => onSetView('workspeac')}>
164
+ <button className={`sidebar-top-action ${view === 'workspace' ? 'is-active' : ''}`} onClick={() => onSetView('workspace')}>
145
165
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
146
166
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
147
167
  <path d="M3 6h7l2 2h9v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
148
168
  <path d="M3 6v12"></path>
149
169
  </svg>
150
170
  </span>
151
- <span>Workspeac</span>
171
+ <span>Workspace</span>
152
172
  </button>
153
173
  <button className={`sidebar-top-action ${modelVisible ? 'is-active' : ''}`} onClick={onToggleModel}>
154
174
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
@@ -160,6 +180,45 @@ export default function DashboardSidebar({
160
180
  <span>Live2D Model</span>
161
181
  </button>
162
182
 
183
+ <div className="sidebar-more-container" ref={moreContainerRef}>
184
+ <button className={`sidebar-top-action ${isMoreOpen || view === 'imagine' || view === 'workflows' ? 'is-active' : ''}`} onClick={() => setIsMoreOpen(!isMoreOpen)}>
185
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
186
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
187
+ <circle cx="12" cy="12" r="1.5"></circle>
188
+ <circle cx="19" cy="12" r="1.5"></circle>
189
+ <circle cx="5" cy="12" r="1.5"></circle>
190
+ </svg>
191
+ </span>
192
+ <span>More</span>
193
+ </button>
194
+ {isMoreOpen && (
195
+ <div className="sidebar-more-popover">
196
+ <button className={`popover-item ${view === 'imagine' ? 'active' : ''}`} onClick={() => { onSetView('imagine'); setIsMoreOpen(false); }}>
197
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
198
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
199
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
200
+ </svg>
201
+ </span>
202
+ <span>Image Studio</span>
203
+ </button>
204
+ <button className={`popover-item ${view === 'workflows' ? 'active' : ''}`} onClick={() => { onSetView('workflows'); setIsMoreOpen(false); }}>
205
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
206
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
207
+ <path d="M4 12h16"></path>
208
+ <path d="M12 4v16"></path>
209
+ <rect x="2" y="9" width="4" height="6" rx="1"></rect>
210
+ <rect x="18" y="9" width="4" height="6" rx="1"></rect>
211
+ <rect x="10" y="2" width="4" height="4" rx="1"></rect>
212
+ <rect x="10" y="18" width="4" height="4" rx="1"></rect>
213
+ </svg>
214
+ </span>
215
+ <span>Workflow (Beta)</span>
216
+ </button>
217
+ </div>
218
+ )}
219
+ </div>
220
+
221
+
163
222
  {modelVisible && (
164
223
  <div className="sidebar-model-controls">
165
224
  <button
@@ -193,12 +193,16 @@ export default function ImageStudioPanel({ view, onRefreshPictures, onSendToChat
193
193
  document.body.removeChild(a)
194
194
  }
195
195
 
196
- if (view !== 'imagine') return null
197
-
198
196
  const modelOptions = PROVIDER_MODELS[selectedProvider] ?? []
199
197
 
200
198
  return (
201
- <div className="img-studio" id="image-studio-panel" role="main" aria-label="Image Studio">
199
+ <div
200
+ className={`img-studio ${view === 'imagine' ? 'is-visible' : ''}`}
201
+ id="image-studio-panel"
202
+ role="main"
203
+ aria-label="Image Studio"
204
+ aria-hidden={view !== 'imagine'}
205
+ >
202
206
  {/* Header */}
203
207
  <header className="img-studio-header">
204
208
  <div className="img-studio-header-title">