@pheem49/mint 1.5.5 → 1.6.1
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/.codex +0 -0
- package/.github/FUNDING.yml +2 -0
- package/.github/workflows/ci.yml +45 -0
- package/.github/workflows/release.yml +79 -0
- package/Cargo.lock +5792 -0
- package/Cargo.toml +32 -0
- package/README.md +387 -353
- package/assets/icon.png +0 -0
- package/bin/mint +0 -0
- package/crates/mint-cli/Cargo.toml +23 -0
- package/crates/mint-cli/src/agent.rs +851 -0
- package/crates/mint-cli/src/gmail.rs +216 -0
- package/crates/mint-cli/src/image.rs +142 -0
- package/crates/mint-cli/src/main.rs +2837 -0
- package/crates/mint-cli/src/mcp.rs +63 -0
- package/crates/mint-cli/src/onboard.rs +1149 -0
- package/crates/mint-cli/src/setup.rs +390 -0
- package/crates/mint-cli/src/skills.rs +8 -0
- package/crates/mint-cli/src/updater.rs +279 -0
- package/crates/mint-core/Cargo.toml +22 -0
- package/crates/mint-core/src/agent_loop.rs +94 -0
- package/crates/mint-core/src/api_server.rs +991 -0
- package/crates/mint-core/src/channels.rs +248 -0
- package/crates/mint-core/src/chat.rs +895 -0
- package/crates/mint-core/src/code_tools.rs +729 -0
- package/crates/mint-core/src/config.rs +368 -0
- package/crates/mint-core/src/files.rs +159 -0
- package/crates/mint-core/src/knowledge.rs +541 -0
- package/crates/mint-core/src/lib.rs +84 -0
- package/crates/mint-core/src/mcp.rs +273 -0
- package/crates/mint-core/src/memory.rs +673 -0
- package/crates/mint-core/src/orchestration.rs +2157 -0
- package/crates/mint-core/src/pictures.rs +314 -0
- package/crates/mint-core/src/plugins.rs +727 -0
- package/crates/mint-core/src/safety.rs +416 -0
- package/crates/mint-core/src/semantic.rs +254 -0
- package/crates/mint-core/src/shell.rs +317 -0
- package/crates/mint-core/src/skills.rs +71 -0
- package/crates/mint-core/src/symbols.rs +157 -0
- package/crates/mint-core/src/tasks.rs +308 -0
- package/crates/mint-core/src/tts.rs +92 -0
- package/crates/mint-core/src/weather.rs +93 -0
- package/crates/mint-core/src/web_search.rs +200 -0
- package/crates/mint-core/src/workflows.rs +81 -0
- package/crates/mint-core/tests/mcp_stdio.rs +45 -0
- package/crates/mint-core/tests/memory_persistence.rs +172 -0
- package/crates/mint-core/tests/pictures_storage.rs +14 -0
- package/crates/mint-core/tests/task_lifecycle.rs +87 -0
- package/package.json +35 -99
- package/src/bin/index.js +16 -0
- package/src/renderer/index-web.html +17 -0
- package/src/renderer/index.html +17 -0
- package/src/renderer/public/Live2DCubismCore.js +9 -0
- package/src/renderer/public/assets/icon.png +0 -0
- package/src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.model3.json +36 -0
- package/src/renderer/src/App.tsx +33 -0
- package/src/renderer/src/calculator.ts +47 -0
- package/src/renderer/src/components/ChatPanel.tsx +1598 -0
- package/src/renderer/src/components/DashboardSidebar.tsx +358 -0
- package/src/renderer/src/components/Live2DStage.tsx +374 -0
- package/src/renderer/src/components/MintDashboard.tsx +950 -0
- package/src/renderer/src/components/ModelPanel.tsx +154 -0
- package/src/renderer/src/components/PicturesLibrary.tsx +46 -0
- package/src/renderer/src/components/ProactiveGlow.tsx +19 -0
- package/src/renderer/src/components/ScreenPicker.tsx +579 -0
- package/src/renderer/src/components/SettingsWindow.tsx +1467 -0
- package/src/renderer/src/components/SpotlightWindow.tsx +280 -0
- package/src/renderer/src/components/WidgetWindow.tsx +36 -0
- package/src/renderer/src/components/WorkspacePanel.tsx +268 -0
- package/src/{UI → renderer/src/css}/settings.css +69 -16
- package/src/renderer/src/css/spotlight.css +113 -0
- package/src/renderer/src/css/styles.css +3722 -0
- package/src/renderer/src/css/widget.css +185 -0
- package/src/renderer/src/env.d.ts +116 -0
- package/src/renderer/src/index.css +379 -0
- package/src/renderer/src/main.tsx +13 -0
- package/src/renderer/src/tauri.ts +996 -0
- package/src/renderer/src-web/App.tsx +25 -0
- package/src/renderer/src-web/calculator.ts +47 -0
- package/src/renderer/src-web/components/ChatPanel.tsx +1662 -0
- package/src/renderer/src-web/components/DashboardSidebar.tsx +242 -0
- package/src/renderer/src-web/components/MintDashboard.tsx +763 -0
- package/src/renderer/src-web/components/PicturesLibrary.tsx +73 -0
- package/src/renderer/src-web/components/SettingsWindow.tsx +1500 -0
- package/src/renderer/src-web/css/settings.css +1100 -0
- package/src/{UI → renderer/src-web/css}/spotlight.css +4 -4
- package/src/{UI → renderer/src-web/css}/styles.css +1055 -159
- package/src/{UI → renderer/src-web/css}/widget.css +2 -2
- package/src/renderer/src-web/env.d.ts +107 -0
- package/src/renderer/src-web/index.css +379 -0
- package/src/renderer/src-web/main.tsx +13 -0
- package/src/renderer/src-web/tauri.ts +983 -0
- package/tsconfig.json +30 -0
- package/vite.config.ts +33 -0
- package/vite.config.web.ts +51 -0
- package/GUIDE_TH.md +0 -125
- package/assets/Agent_Mint.png +0 -0
- package/assets/CLI_Screen.png +0 -0
- package/assets/Settings.png +0 -0
- package/benchmark_ai.js +0 -71
- package/install.ps1 +0 -64
- package/install.sh +0 -54
- package/main.js +0 -139
- package/mint-cli-logic.js +0 -3
- package/mint-cli.js +0 -410
- package/models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.model3.json +0 -47
- package/models/Shiroko_Model/Shiroko//342/232/241/351/253/230/344/272/256/342/232/241/344/275/277/347/224/250/346/225/231/347/250/213/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.txt +0 -23
- package/preload-picker.js +0 -11
- package/preload-settings.js +0 -11
- package/preload.js +0 -41
- package/scripts/install_linux_desktop_entry.js +0 -48
- package/src/AI_Brain/Gemini_API.js +0 -813
- package/src/AI_Brain/agent_orchestrator.js +0 -73
- package/src/AI_Brain/autonomous_brain.js +0 -179
- package/src/AI_Brain/behavior_memory.js +0 -135
- package/src/AI_Brain/headless_agent.js +0 -143
- package/src/AI_Brain/knowledge_base.js +0 -349
- package/src/AI_Brain/memory_store.js +0 -662
- package/src/AI_Brain/proactive_engine.js +0 -172
- package/src/AI_Brain/provider_adapter.js +0 -365
- package/src/Automation_Layer/browser_automation.js +0 -149
- package/src/Automation_Layer/file_operations.js +0 -286
- package/src/Automation_Layer/open_app.js +0 -85
- package/src/Automation_Layer/open_website.js +0 -38
- package/src/CLI/approval_handler.js +0 -47
- package/src/CLI/chat_router.js +0 -247
- package/src/CLI/chat_ui.js +0 -1159
- package/src/CLI/cli_colors.js +0 -115
- package/src/CLI/cli_formatters.js +0 -94
- package/src/CLI/code_agent.js +0 -1667
- package/src/CLI/code_session_memory.js +0 -62
- package/src/CLI/gmail_auth.js +0 -210
- package/src/CLI/image_input.js +0 -90
- package/src/CLI/intent_detectors.js +0 -181
- package/src/CLI/interactive_chat.js +0 -658
- package/src/CLI/list_features.js +0 -64
- package/src/CLI/onboarding.js +0 -416
- package/src/CLI/repo_summarizer.js +0 -282
- package/src/CLI/semantic_code_search.js +0 -312
- package/src/CLI/skill_manager.js +0 -41
- package/src/CLI/slash_command_handler.js +0 -418
- package/src/CLI/symbol_indexer.js +0 -231
- package/src/CLI/updater.js +0 -230
- package/src/CLI/workspace_manager.js +0 -90
- package/src/Channels/brave_search_bridge.js +0 -35
- package/src/Channels/discord_bridge.js +0 -66
- package/src/Channels/google_search_bridge.js +0 -38
- package/src/Channels/line_bridge.js +0 -60
- package/src/Channels/slack_bridge.js +0 -48
- package/src/Channels/telegram_bridge.js +0 -41
- package/src/Channels/whatsapp_bridge.js +0 -57
- package/src/Command_Parser/parser.js +0 -45
- package/src/Plugins/dev_tools.js +0 -41
- package/src/Plugins/discord.js +0 -20
- package/src/Plugins/docker.js +0 -47
- package/src/Plugins/gmail.js +0 -251
- package/src/Plugins/google_calendar.js +0 -252
- package/src/Plugins/mcp_manager.js +0 -95
- package/src/Plugins/notion.js +0 -256
- package/src/Plugins/obsidian.js +0 -54
- package/src/Plugins/plugin_manager.js +0 -81
- package/src/Plugins/spotify.js +0 -173
- package/src/Plugins/system_metrics.js +0 -31
- package/src/Plugins/system_monitor.js +0 -72
- package/src/System/action_executor.js +0 -178
- package/src/System/bridge_manager.js +0 -76
- package/src/System/chat_history_manager.js +0 -83
- package/src/System/config_manager.js +0 -194
- package/src/System/custom_workflows.js +0 -163
- package/src/System/daemon_manager.js +0 -67
- package/src/System/google_tts_urls.js +0 -51
- package/src/System/granular_automation.js +0 -157
- package/src/System/ipc_handlers.js +0 -332
- package/src/System/notifications.js +0 -23
- package/src/System/optional_require.js +0 -23
- package/src/System/picture_store.js +0 -109
- package/src/System/proactive_loop.js +0 -153
- package/src/System/safety_manager.js +0 -273
- package/src/System/sandbox_runner.js +0 -182
- package/src/System/screen_capture.js +0 -175
- package/src/System/smart_context.js +0 -227
- package/src/System/system_automation.js +0 -162
- package/src/System/system_events.js +0 -79
- package/src/System/system_info.js +0 -125
- package/src/System/task_manager.js +0 -222
- package/src/System/tool_registry.js +0 -293
- package/src/System/window_manager.js +0 -220
- package/src/UI/floating.css +0 -80
- package/src/UI/floating.html +0 -17
- package/src/UI/floating.js +0 -67
- package/src/UI/live2d_manager.js +0 -600
- package/src/UI/preload-floating.js +0 -7
- package/src/UI/preload-spotlight.js +0 -11
- package/src/UI/preload-widget.js +0 -5
- package/src/UI/proactive-glow.html +0 -42
- package/src/UI/renderer.js +0 -2127
- package/src/UI/screenPicker.html +0 -214
- package/src/UI/screenPicker.js +0 -262
- package/src/UI/settings.html +0 -577
- package/src/UI/settings.js +0 -770
- package/src/UI/spotlight.html +0 -23
- package/src/UI/spotlight.js +0 -185
- package/src/UI/widget.html +0 -29
- package/src/UI/widget.js +0 -10
- /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/72d86db84cfa9730b894c241fd24c0db.png +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/233/264/350/243/231.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/apron.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/214/253/345/222/252/346/273/244/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/catfilter.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/click.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazed.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253/347/234/274/347/217/240/346/221/207/346/231/203.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazedeyes.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/234/274/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/glasses.exp3.json} +0 -0
- /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/items_pinned_to_model.json +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/277/347/254/224.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/pen.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/215/347/205/247.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/photo.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_00.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_00.png} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_01.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_01.png} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_02.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_02.png} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_03.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_03.png} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.cdi3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.cdi3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.moc3" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.moc3} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.physics3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.physics3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.vtube.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.vtube.json} +0 -0
|
@@ -0,0 +1,1662 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, Fragment, type ChangeEvent, type ClipboardEvent, type FormEvent, type KeyboardEvent, type RefObject, type DragEvent } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
type AgentProgress,
|
|
4
|
+
type ChatResponse,
|
|
5
|
+
type RuntimeStatus,
|
|
6
|
+
getTtsUrls,
|
|
7
|
+
} from '../tauri'
|
|
8
|
+
|
|
9
|
+
const GEMINI_MODELS = [
|
|
10
|
+
'gemini-2.5-flash',
|
|
11
|
+
'gemini-2.5-pro',
|
|
12
|
+
'gemini-2.0-flash',
|
|
13
|
+
'gemini-1.5-flash',
|
|
14
|
+
'gemini-1.5-pro',
|
|
15
|
+
'gemini-3.1-flash-lite',
|
|
16
|
+
'gemini-3.1-flash-lite-preview'
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const OPENAI_MODELS = [
|
|
20
|
+
'gpt-4o',
|
|
21
|
+
'gpt-4o-mini',
|
|
22
|
+
'o1',
|
|
23
|
+
'o3-mini',
|
|
24
|
+
'o1-preview',
|
|
25
|
+
'o1-mini',
|
|
26
|
+
'gpt-4-turbo'
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const OPENROUTER_MODELS = [
|
|
30
|
+
'openai/gpt-4o-mini',
|
|
31
|
+
'openai/gpt-4o',
|
|
32
|
+
'anthropic/claude-3.5-sonnet',
|
|
33
|
+
'anthropic/claude-3.5-haiku',
|
|
34
|
+
'google/gemini-2.5-flash',
|
|
35
|
+
'meta-llama/llama-3.3-70b-instruct',
|
|
36
|
+
'mistralai/mistral-large'
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const DEEPSEEK_MODELS = [
|
|
40
|
+
'deepseek-v4-flash',
|
|
41
|
+
'deepseek-v4-pro',
|
|
42
|
+
'deepseek-chat',
|
|
43
|
+
'deepseek-reasoner'
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const ANTHROPIC_MODELS = [
|
|
47
|
+
'claude-3-7-sonnet-latest',
|
|
48
|
+
'claude-3-5-sonnet-latest',
|
|
49
|
+
'claude-3-5-haiku-latest',
|
|
50
|
+
'claude-3-opus-latest'
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const HF_MODELS = [
|
|
54
|
+
'meta-llama/Llama-3.3-70B-Instruct',
|
|
55
|
+
'meta-llama/Meta-Llama-3-8B-Instruct',
|
|
56
|
+
'meta-llama/Llama-3.2-3B-Instruct',
|
|
57
|
+
'Qwen/Qwen2.5-72B-Instruct',
|
|
58
|
+
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
|
59
|
+
'mistralai/Mistral-7B-Instruct-v0.3',
|
|
60
|
+
'google/gemma-2-9b-it'
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
const LOCAL_MODELS = [
|
|
64
|
+
'local-model',
|
|
65
|
+
'Qwen/Qwen2.5-7B-Instruct-GGUF',
|
|
66
|
+
'meta-llama/Llama-3.2-3B-Instruct-GGUF',
|
|
67
|
+
'lmstudio-community/gemma-2-9b-it-GGUF'
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
const OLLAMA_MODELS = [
|
|
71
|
+
'llama3:latest',
|
|
72
|
+
'llama3.1:latest',
|
|
73
|
+
'llama3.2:latest',
|
|
74
|
+
'gemma2:latest',
|
|
75
|
+
'mistral:latest',
|
|
76
|
+
'phi3:latest',
|
|
77
|
+
'qwen2.5:latest'
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
interface ApprovalDetails {
|
|
81
|
+
title: string
|
|
82
|
+
body: string
|
|
83
|
+
reason?: string
|
|
84
|
+
isDangerous: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function badge(provider: string, model: string) {
|
|
88
|
+
return [provider, model].filter(Boolean).join(' / ')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function providerLabel(provider: string) {
|
|
92
|
+
switch (provider) {
|
|
93
|
+
case 'gemini':
|
|
94
|
+
return 'Gemini'
|
|
95
|
+
case 'openai':
|
|
96
|
+
return 'OpenAI'
|
|
97
|
+
case 'openrouter':
|
|
98
|
+
return 'OpenRouter'
|
|
99
|
+
case 'deepseek':
|
|
100
|
+
return 'DeepSeek'
|
|
101
|
+
case 'anthropic':
|
|
102
|
+
return 'Claude'
|
|
103
|
+
case 'huggingface':
|
|
104
|
+
return 'Hugging Face'
|
|
105
|
+
case 'local_openai':
|
|
106
|
+
return 'Local OpenAI'
|
|
107
|
+
case 'ollama':
|
|
108
|
+
return 'Ollama'
|
|
109
|
+
default:
|
|
110
|
+
return provider || 'Primary provider'
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function fallbackNotice(response: Pick<ChatResponse, 'provider' | 'fallbackProvider'> | null | undefined) {
|
|
115
|
+
if (!response?.fallbackProvider) return ''
|
|
116
|
+
return `${providerLabel(response.fallbackProvider)} unavailable, fell back to ${providerLabel(response.provider)}.`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface AgentActivity {
|
|
120
|
+
label: string
|
|
121
|
+
target: string
|
|
122
|
+
kind: 'file' | 'folder' | 'search' | 'terminal' | 'tool'
|
|
123
|
+
state: 'active' | 'done' | 'error'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface AgentActivityView {
|
|
127
|
+
summary: string
|
|
128
|
+
items: AgentActivity[]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function activityDetail(input: Record<string, unknown>, key: string) {
|
|
132
|
+
const value = input[key]
|
|
133
|
+
return typeof value === 'string' && value.trim() ? value : ''
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatActivityTarget(value: string) {
|
|
137
|
+
const compact = value.replace(/^\/home\/([^/]+)/, '~')
|
|
138
|
+
return compact || 'workspace'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function activityKind(action: string, target: string): AgentActivity['kind'] {
|
|
142
|
+
if (['search_code', 'semantic_search', 'knowledge_search', 'web_search', 'memory_recall'].includes(action)) return 'search'
|
|
143
|
+
if (['run_shell', 'verify'].includes(action)) return 'terminal'
|
|
144
|
+
if (['list_files', 'detect_project'].includes(action)) return 'folder'
|
|
145
|
+
if (['read_file', 'symbols', 'read_diagnostics', 'git_diff', 'apply_patch', 'write_file', 'note_write', 'view_image'].includes(action)) return 'file'
|
|
146
|
+
return target.includes('/') && !/\.[^/]+$/.test(target) ? 'folder' : 'tool'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function describeTool(action: string, input: Record<string, unknown>): AgentActivity {
|
|
150
|
+
const path = activityDetail(input, 'path')
|
|
151
|
+
const query = activityDetail(input, 'query')
|
|
152
|
+
const command = activityDetail(input, 'command')
|
|
153
|
+
const name = activityDetail(input, 'name')
|
|
154
|
+
const tool = activityDetail(input, 'tool')
|
|
155
|
+
const target = path || query || command || name || tool || action.replaceAll('_', ' ')
|
|
156
|
+
const labels: Record<string, string> = {
|
|
157
|
+
apply_patch: 'Applying patch',
|
|
158
|
+
ask_user: 'Asking user',
|
|
159
|
+
create_plan: 'Creating plan',
|
|
160
|
+
detect_project: 'Detecting project',
|
|
161
|
+
git_branch: 'Reading branch',
|
|
162
|
+
git_diff: 'Reading diff',
|
|
163
|
+
git_log: 'Reading log',
|
|
164
|
+
git_status: 'Reading git status',
|
|
165
|
+
knowledge_search: 'Searching knowledge',
|
|
166
|
+
list_files: 'Listing files',
|
|
167
|
+
list_tests: 'Listing tests',
|
|
168
|
+
mcp_tool: 'Calling MCP tool',
|
|
169
|
+
memory_recall: 'Recalling memory',
|
|
170
|
+
note_write: 'Writing note',
|
|
171
|
+
read_diagnostics: 'Reading diagnostics',
|
|
172
|
+
read_file: 'Reading file',
|
|
173
|
+
request_user_approval: 'Requesting approval',
|
|
174
|
+
run_plugin: 'Running plugin',
|
|
175
|
+
run_shell: 'Running command',
|
|
176
|
+
search_code: 'Searching code',
|
|
177
|
+
semantic_index: 'Indexing code',
|
|
178
|
+
semantic_search: 'Searching code',
|
|
179
|
+
symbols: 'Inspecting symbols',
|
|
180
|
+
update_plan: 'Updating plan',
|
|
181
|
+
verify: 'Verifying',
|
|
182
|
+
view_image: 'Viewing image',
|
|
183
|
+
web_search: 'Searching web',
|
|
184
|
+
write_file: 'Writing file',
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
label: labels[action] ?? 'Using tool',
|
|
188
|
+
target: formatActivityTarget(target),
|
|
189
|
+
kind: activityKind(action, target),
|
|
190
|
+
state: 'active',
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function activitySummary(items: AgentActivity[]) {
|
|
195
|
+
const files = new Set<string>()
|
|
196
|
+
const folders = new Set<string>()
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
if (item.kind === 'file') files.add(item.target)
|
|
199
|
+
if (item.kind === 'folder') folders.add(item.target)
|
|
200
|
+
}
|
|
201
|
+
const parts = [
|
|
202
|
+
files.size ? `${files.size} ${files.size === 1 ? 'file' : 'files'}` : '',
|
|
203
|
+
folders.size ? `${folders.size} ${folders.size === 1 ? 'folder' : 'folders'}` : '',
|
|
204
|
+
].filter(Boolean)
|
|
205
|
+
return parts.length ? `Exploring ${parts.join(', ')}` : 'Working through task'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function activitiesFrom(progress: AgentProgress[]): AgentActivityView {
|
|
209
|
+
const activities: AgentActivity[] = []
|
|
210
|
+
for (const event of progress) {
|
|
211
|
+
if (event.type === 'ToolStart') {
|
|
212
|
+
activities.push(describeTool(event.data.action, event.data.input))
|
|
213
|
+
} else if (event.type === 'ToolEnd') {
|
|
214
|
+
for (let index = activities.length - 1; index >= 0; index -= 1) {
|
|
215
|
+
if (activities[index].state !== 'active') continue
|
|
216
|
+
activities[index].state = event.data.result.startsWith('Error:') ? 'error' : 'done'
|
|
217
|
+
if (activities[index].state === 'error') activities[index].label = 'Failed'
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const items = activities.slice(-12)
|
|
223
|
+
return { summary: activitySummary(activities), items }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderAgentActivityTable(activityView: AgentActivityView) {
|
|
227
|
+
return (
|
|
228
|
+
<div className="agent-activity-list">
|
|
229
|
+
<div className="agent-activity-table-head" aria-hidden="true">
|
|
230
|
+
<span>Tool</span>
|
|
231
|
+
<span />
|
|
232
|
+
<span>Target</span>
|
|
233
|
+
<span />
|
|
234
|
+
</div>
|
|
235
|
+
{activityView.items.map((activity, index) => (
|
|
236
|
+
<div className="agent-activity-item" data-kind={activity.kind} data-state={activity.state} key={`${index}-${activity.label}-${activity.target}`}>
|
|
237
|
+
<span className="agent-activity-label">{activity.label}</span>
|
|
238
|
+
<span className="agent-activity-icon" aria-hidden="true" />
|
|
239
|
+
<span className="agent-activity-text">{activity.target}</span>
|
|
240
|
+
<span className="agent-activity-chevron" aria-hidden="true">></span>
|
|
241
|
+
</div>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderApprovalDetails(approval: any): ApprovalDetails {
|
|
248
|
+
if (!approval) return { title: 'Action Pending Approval', body: 'No action details available.', isDangerous: false }
|
|
249
|
+
if (approval.WriteFile) return { title: 'Write File', body: `Path: ${approval.WriteFile.path}`, reason: approval.WriteFile.diff ? `Diff:\n${approval.WriteFile.diff}` : 'Writing new file content.', isDangerous: false }
|
|
250
|
+
if (approval.ApplyPatch) return { title: 'Apply Patch', body: `Path: ${approval.ApplyPatch.path}`, reason: approval.ApplyPatch.diff ? `Diff:\n${approval.ApplyPatch.diff}` : 'Applying code patch.', isDangerous: false }
|
|
251
|
+
if (approval.RunShell) return { title: 'Run Shell Command', body: approval.RunShell.command, reason: 'Executing shell commands can modify your system.', isDangerous: true }
|
|
252
|
+
if (approval.NoteWrite) return { title: 'Write Note', body: `Path: ${approval.NoteWrite.path}`, reason: 'Creating or updating workspace notes.', isDangerous: false }
|
|
253
|
+
if (approval.RunPlugin) return { title: `Run Plugin: ${approval.RunPlugin.name}`, body: approval.RunPlugin.instruction, reason: 'Executing a native plugin action.', isDangerous: false }
|
|
254
|
+
if (approval.McpTool) {
|
|
255
|
+
const { server, tool, arguments: args } = approval.McpTool
|
|
256
|
+
return { title: `Run MCP Tool: ${server}/${tool}`, body: typeof args === 'string' ? args : JSON.stringify(args, null, 2), reason: 'Running external MCP tool.', isDangerous: false }
|
|
257
|
+
}
|
|
258
|
+
if (approval.UserApproval) return { title: approval.UserApproval.title, body: approval.UserApproval.prompt, reason: 'The agent requested explicit approval.', isDangerous: false }
|
|
259
|
+
if (approval.AskUser) return { title: 'Question From Agent', body: approval.AskUser.question, reason: 'Approve to continue without a typed answer, or cancel to decline.', isDangerous: false }
|
|
260
|
+
return { title: 'Unknown Action', body: JSON.stringify(approval, null, 2), reason: 'Requires approval to proceed.', isDangerous: false }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface ChatPanelProps {
|
|
264
|
+
interactions: any[]
|
|
265
|
+
sending: boolean
|
|
266
|
+
sendingMessage: string
|
|
267
|
+
sendingImageCount: number
|
|
268
|
+
streamedReply: string
|
|
269
|
+
streamedResponse: ChatResponse | null
|
|
270
|
+
agentProgress: AgentProgress[]
|
|
271
|
+
agentActivitySnapshots: Record<string, AgentProgress[]>
|
|
272
|
+
message: string
|
|
273
|
+
imageAttachments: Array<{ dataUri: string; name: string; previewDataUri?: string }>
|
|
274
|
+
documentName: string
|
|
275
|
+
pendingApproval: any | null
|
|
276
|
+
smartContext: boolean
|
|
277
|
+
agentMode: boolean
|
|
278
|
+
status: RuntimeStatus | null
|
|
279
|
+
chatEnd: RefObject<HTMLDivElement | null>
|
|
280
|
+
welcomeInteraction: any
|
|
281
|
+
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
|
282
|
+
onSelectImage: (event: ChangeEvent<HTMLInputElement>) => void
|
|
283
|
+
onSelectDocument: (event: ChangeEvent<HTMLInputElement>) => void
|
|
284
|
+
onPasteImage: (clipboardData: DataTransfer) => boolean
|
|
285
|
+
onReadClipboardImage: () => Promise<boolean>
|
|
286
|
+
onSetMessage: (message: string) => void
|
|
287
|
+
onSendVoiceMessage: (message: string, audioDataUri?: string | null) => Promise<void>
|
|
288
|
+
onRemoveImage: (idx: number) => void
|
|
289
|
+
onRemoveDocument: () => void
|
|
290
|
+
onStartWebSearch: () => void
|
|
291
|
+
onCaptureScreen: () => void
|
|
292
|
+
onSetSmartContext: (enabled: boolean) => void
|
|
293
|
+
onSetAgentMode: (enabled: boolean) => void
|
|
294
|
+
onSetProvider: (provider: string) => void
|
|
295
|
+
onApproval: (approved: boolean, autoApproveSession?: boolean) => void
|
|
296
|
+
onToggleMobileSidebar: () => void
|
|
297
|
+
settingsConfig: any
|
|
298
|
+
onSetModel: (model: string) => void
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function renderFormattedMessage(text: string) {
|
|
302
|
+
const displayText = readableAssistantText(text)
|
|
303
|
+
if (!displayText) return null
|
|
304
|
+
|
|
305
|
+
const lines = displayText.split('\n')
|
|
306
|
+
return lines.map((line, lineIndex) => {
|
|
307
|
+
const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
|
|
308
|
+
|
|
309
|
+
const formatInline = (str: string) => {
|
|
310
|
+
const parts = str.split(/\*\*([\s\S]*?)\*\*/g)
|
|
311
|
+
return parts.map((part, partIndex) => {
|
|
312
|
+
if (partIndex % 2 === 1) {
|
|
313
|
+
return (
|
|
314
|
+
<strong key={partIndex} className="chat-bold-highlight">
|
|
315
|
+
{part}
|
|
316
|
+
</strong>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
return part
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (headerMatch) {
|
|
324
|
+
const level = headerMatch[1].length
|
|
325
|
+
const content = headerMatch[2]
|
|
326
|
+
|
|
327
|
+
const style = {
|
|
328
|
+
fontWeight: 'bold',
|
|
329
|
+
display: 'block',
|
|
330
|
+
marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
|
|
331
|
+
marginBottom: '6px',
|
|
332
|
+
fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
|
|
333
|
+
color: 'var(--text-main)'
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<span key={lineIndex} style={style}>
|
|
338
|
+
{formatInline(content)}
|
|
339
|
+
</span>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<Fragment key={lineIndex}>
|
|
345
|
+
{formatInline(line)}
|
|
346
|
+
{lineIndex < lines.length - 1 && '\n'}
|
|
347
|
+
</Fragment>
|
|
348
|
+
)
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function readableAssistantText(text: string) {
|
|
353
|
+
if (typeof text !== 'string') return ''
|
|
354
|
+
const trimmed = text.trim()
|
|
355
|
+
if (!trimmed.startsWith('{')) return text
|
|
356
|
+
try {
|
|
357
|
+
const value = JSON.parse(trimmed)
|
|
358
|
+
if (value?.action === 'finish' && typeof value?.input?.summary === 'string' && value.input.summary.trim()) {
|
|
359
|
+
return value.input.summary
|
|
360
|
+
}
|
|
361
|
+
if (typeof value?.finish?.summary === 'string' && value.finish.summary.trim()) {
|
|
362
|
+
return value.finish.summary
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
return text
|
|
366
|
+
}
|
|
367
|
+
return text
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function renderSpeakerIcon(isSpeaking: boolean) {
|
|
371
|
+
if (isSpeaking) {
|
|
372
|
+
return (
|
|
373
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
|
|
374
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
375
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
376
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
377
|
+
</svg>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
return (
|
|
381
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
|
|
382
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
383
|
+
</svg>
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function cleanSpeechText(text: string) {
|
|
388
|
+
return readableAssistantText(text)
|
|
389
|
+
.replace(/\*\*([\s\S]*?)\*\*/g, '$1')
|
|
390
|
+
.replace(/[*_`#]/g, '')
|
|
391
|
+
.trim()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function numericSetting(value: unknown, fallback: number) {
|
|
395
|
+
const numeric = Number(value)
|
|
396
|
+
return Number.isFinite(numeric) ? numeric : fallback
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
interface DiffHunk {
|
|
400
|
+
oldText: string
|
|
401
|
+
newText: string
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
interface FileChange {
|
|
405
|
+
path: string
|
|
406
|
+
created: boolean
|
|
407
|
+
additions: number
|
|
408
|
+
deletions: number
|
|
409
|
+
hunks: DiffHunk[]
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function parseFileChangesFromProgress(progress: AgentProgress[]): FileChange[] {
|
|
413
|
+
const changes = new Map<string, FileChange>()
|
|
414
|
+
let activeEdit: { action: string; path: string; created: boolean; additions: number; deletions: number; hunks: DiffHunk[] } | null = null
|
|
415
|
+
|
|
416
|
+
for (const event of progress || []) {
|
|
417
|
+
if (event.type === 'ToolStart') {
|
|
418
|
+
if (event.data.action === 'apply_patch') {
|
|
419
|
+
const patch = (event.data.input as any)?.patch
|
|
420
|
+
if (patch && typeof patch.path === 'string') {
|
|
421
|
+
let additions = 0
|
|
422
|
+
let deletions = 0
|
|
423
|
+
const hunksList: DiffHunk[] = []
|
|
424
|
+
const hunks = patch.hunks
|
|
425
|
+
if (Array.isArray(hunks)) {
|
|
426
|
+
for (const hunk of hunks) {
|
|
427
|
+
const oldText = hunk?.oldText || ''
|
|
428
|
+
const newText = hunk?.newText || ''
|
|
429
|
+
const oldLines = oldText ? oldText.split('\n').length : 0
|
|
430
|
+
const newLines = newText ? newText.split('\n').length : 0
|
|
431
|
+
deletions += oldLines
|
|
432
|
+
additions += newLines
|
|
433
|
+
hunksList.push({ oldText, newText })
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
activeEdit = {
|
|
437
|
+
action: 'apply_patch',
|
|
438
|
+
path: patch.path,
|
|
439
|
+
created: false,
|
|
440
|
+
additions,
|
|
441
|
+
deletions,
|
|
442
|
+
hunks: hunksList
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} else if (event.data.action === 'write_file') {
|
|
446
|
+
const path = (event.data.input as any)?.path
|
|
447
|
+
const fileContent = (event.data.input as any)?.file_content || ''
|
|
448
|
+
if (typeof path === 'string') {
|
|
449
|
+
const additions = fileContent ? fileContent.split('\n').length : 0
|
|
450
|
+
activeEdit = {
|
|
451
|
+
action: 'write_file',
|
|
452
|
+
path,
|
|
453
|
+
created: true,
|
|
454
|
+
additions,
|
|
455
|
+
deletions: 0,
|
|
456
|
+
hunks: [{ oldText: '', newText: fileContent }]
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
activeEdit = null
|
|
461
|
+
}
|
|
462
|
+
} else if (event.type === 'ToolEnd') {
|
|
463
|
+
if (activeEdit && (event.data.action === 'apply_patch' || event.data.action === 'write_file')) {
|
|
464
|
+
const isError = typeof event.data.result === 'string' && event.data.result.startsWith('Error:')
|
|
465
|
+
if (!isError) {
|
|
466
|
+
try {
|
|
467
|
+
const applied = JSON.parse(event.data.result)
|
|
468
|
+
const appliedPaths = Array.isArray(applied) ? applied.map(item => item?.path).filter(Boolean) : [activeEdit.path]
|
|
469
|
+
|
|
470
|
+
for (const path of appliedPaths) {
|
|
471
|
+
const existing = changes.get(path)
|
|
472
|
+
if (existing) {
|
|
473
|
+
existing.additions += activeEdit.additions
|
|
474
|
+
existing.deletions += activeEdit.deletions
|
|
475
|
+
existing.hunks.push(...activeEdit.hunks)
|
|
476
|
+
} else {
|
|
477
|
+
changes.set(path, {
|
|
478
|
+
path,
|
|
479
|
+
created: activeEdit.created,
|
|
480
|
+
additions: activeEdit.additions,
|
|
481
|
+
deletions: activeEdit.deletions,
|
|
482
|
+
hunks: [...activeEdit.hunks]
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch (e) {
|
|
487
|
+
const path = activeEdit.path
|
|
488
|
+
const existing = changes.get(path)
|
|
489
|
+
if (existing) {
|
|
490
|
+
existing.additions += activeEdit.additions
|
|
491
|
+
existing.deletions += activeEdit.deletions
|
|
492
|
+
existing.hunks.push(...activeEdit.hunks)
|
|
493
|
+
} else {
|
|
494
|
+
changes.set(path, {
|
|
495
|
+
path,
|
|
496
|
+
created: activeEdit.created,
|
|
497
|
+
additions: activeEdit.additions,
|
|
498
|
+
deletions: activeEdit.deletions,
|
|
499
|
+
hunks: [...activeEdit.hunks]
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
activeEdit = null
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return Array.from(changes.values())
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export default function ChatPanel({
|
|
513
|
+
interactions,
|
|
514
|
+
sending,
|
|
515
|
+
sendingMessage,
|
|
516
|
+
sendingImageCount,
|
|
517
|
+
streamedReply,
|
|
518
|
+
streamedResponse,
|
|
519
|
+
agentProgress,
|
|
520
|
+
agentActivitySnapshots,
|
|
521
|
+
message,
|
|
522
|
+
imageAttachments,
|
|
523
|
+
documentName,
|
|
524
|
+
pendingApproval,
|
|
525
|
+
smartContext,
|
|
526
|
+
agentMode,
|
|
527
|
+
status,
|
|
528
|
+
chatEnd,
|
|
529
|
+
welcomeInteraction,
|
|
530
|
+
onSubmit,
|
|
531
|
+
onSelectImage,
|
|
532
|
+
onSelectDocument,
|
|
533
|
+
onPasteImage,
|
|
534
|
+
onReadClipboardImage,
|
|
535
|
+
onSetMessage,
|
|
536
|
+
onSendVoiceMessage,
|
|
537
|
+
onRemoveImage,
|
|
538
|
+
onRemoveDocument,
|
|
539
|
+
onStartWebSearch,
|
|
540
|
+
onCaptureScreen,
|
|
541
|
+
onSetSmartContext,
|
|
542
|
+
onSetAgentMode,
|
|
543
|
+
onSetProvider,
|
|
544
|
+
onApproval,
|
|
545
|
+
onToggleMobileSidebar,
|
|
546
|
+
settingsConfig,
|
|
547
|
+
onSetModel,
|
|
548
|
+
}: ChatPanelProps) {
|
|
549
|
+
const agentActivities = activitiesFrom(agentProgress)
|
|
550
|
+
const activeFallbackNotice = fallbackNotice(streamedResponse)
|
|
551
|
+
const [openActivityIds, setOpenActivityIds] = useState<Record<string, boolean>>({})
|
|
552
|
+
const [openReviewIds, setOpenReviewIds] = useState<Record<string, boolean>>({})
|
|
553
|
+
const [openFileDiffs, setOpenFileDiffs] = useState<Record<string, boolean>>({})
|
|
554
|
+
const [toolMenuOpen, setToolMenuOpen] = useState(false)
|
|
555
|
+
const toolMenuRef = useRef<HTMLDivElement | null>(null)
|
|
556
|
+
const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName)
|
|
557
|
+
const sendingImageMarkers = Array.from({ length: sendingImageCount }, (_, index) => `[Image #${index + 1}]`).join(' ')
|
|
558
|
+
|
|
559
|
+
const getAvailableModels = (provider: string) => {
|
|
560
|
+
switch (provider) {
|
|
561
|
+
case 'gemini':
|
|
562
|
+
return GEMINI_MODELS
|
|
563
|
+
case 'openai':
|
|
564
|
+
return OPENAI_MODELS
|
|
565
|
+
case 'openrouter':
|
|
566
|
+
return OPENROUTER_MODELS
|
|
567
|
+
case 'deepseek':
|
|
568
|
+
return DEEPSEEK_MODELS
|
|
569
|
+
case 'anthropic':
|
|
570
|
+
return ANTHROPIC_MODELS
|
|
571
|
+
case 'huggingface':
|
|
572
|
+
return HF_MODELS
|
|
573
|
+
case 'local_openai':
|
|
574
|
+
return LOCAL_MODELS
|
|
575
|
+
case 'ollama':
|
|
576
|
+
return OLLAMA_MODELS
|
|
577
|
+
default:
|
|
578
|
+
return []
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const activeProvider = status?.activeProvider ?? ''
|
|
583
|
+
const availableModels = getAvailableModels(activeProvider)
|
|
584
|
+
|
|
585
|
+
const getActiveModel = (provider: string) => {
|
|
586
|
+
if (!settingsConfig) return ''
|
|
587
|
+
switch (provider) {
|
|
588
|
+
case 'gemini':
|
|
589
|
+
return settingsConfig.geminiModel
|
|
590
|
+
case 'openai':
|
|
591
|
+
return settingsConfig.openaiModel
|
|
592
|
+
case 'openrouter':
|
|
593
|
+
return settingsConfig.openrouterModel
|
|
594
|
+
case 'deepseek':
|
|
595
|
+
return settingsConfig.deepseekModel
|
|
596
|
+
case 'anthropic':
|
|
597
|
+
return settingsConfig.anthropicModel
|
|
598
|
+
case 'huggingface':
|
|
599
|
+
return settingsConfig.hfModel
|
|
600
|
+
case 'local_openai':
|
|
601
|
+
return settingsConfig.localModelName
|
|
602
|
+
case 'ollama':
|
|
603
|
+
return settingsConfig.ollamaModel
|
|
604
|
+
default:
|
|
605
|
+
return ''
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const activeModel = getActiveModel(activeProvider)
|
|
609
|
+
|
|
610
|
+
const [isRecording, setIsRecording] = useState(false)
|
|
611
|
+
const [voiceMode, setVoiceMode] = useState(false)
|
|
612
|
+
const [voiceTranscript, setVoiceTranscript] = useState('')
|
|
613
|
+
const [voiceAwaitingResponse, setVoiceAwaitingResponse] = useState(false)
|
|
614
|
+
const [speakingText, setSpeakingText] = useState<string | null>(null)
|
|
615
|
+
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
|
616
|
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
|
617
|
+
const mediaStreamRef = useRef<MediaStream | null>(null)
|
|
618
|
+
const audioContextRef = useRef<AudioContext | null>(null)
|
|
619
|
+
const vadTimerRef = useRef<number | null>(null)
|
|
620
|
+
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
621
|
+
const speechRunRef = useRef(0)
|
|
622
|
+
const historyReadyRef = useRef(false)
|
|
623
|
+
const submittedDuringSessionRef = useRef(false)
|
|
624
|
+
const lastAutoSpokenIdRef = useRef<number | string | null>(null)
|
|
625
|
+
const voiceModeRef = useRef(false)
|
|
626
|
+
const sendingRef = useRef(false)
|
|
627
|
+
const voiceAwaitingResponseRef = useRef(false)
|
|
628
|
+
const speakingRef = useRef<string | null>(null)
|
|
629
|
+
const restartTimerRef = useRef<number | null>(null)
|
|
630
|
+
const voiceStatus = speakingText ? 'speaking' : (sending || voiceAwaitingResponse) ? 'thinking' : isRecording ? 'listening' : voiceMode ? 'ready' : 'off'
|
|
631
|
+
const voiceStatusLabel = voiceStatus === 'speaking' ? 'กำลังตอบ' : voiceStatus === 'thinking' ? 'กำลังคิด' : voiceStatus === 'listening' ? 'กำลังฟัง' : 'พร้อมฟัง'
|
|
632
|
+
|
|
633
|
+
const clearRestartTimer = () => {
|
|
634
|
+
if (restartTimerRef.current === null) return
|
|
635
|
+
window.clearTimeout(restartTimerRef.current)
|
|
636
|
+
restartTimerRef.current = null
|
|
637
|
+
}
|
|
638
|
+
const clearVadTimer = () => {
|
|
639
|
+
if (vadTimerRef.current === null) return
|
|
640
|
+
window.clearInterval(vadTimerRef.current)
|
|
641
|
+
vadTimerRef.current = null
|
|
642
|
+
}
|
|
643
|
+
const stopRecognition = () => {
|
|
644
|
+
clearRestartTimer()
|
|
645
|
+
recognitionRef.current?.stop()
|
|
646
|
+
recognitionRef.current = null
|
|
647
|
+
clearVadTimer()
|
|
648
|
+
if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop()
|
|
649
|
+
mediaRecorderRef.current = null
|
|
650
|
+
mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
|
|
651
|
+
mediaStreamRef.current = null
|
|
652
|
+
audioContextRef.current?.close().catch(() => {})
|
|
653
|
+
audioContextRef.current = null
|
|
654
|
+
setIsRecording(false)
|
|
655
|
+
}
|
|
656
|
+
const scheduleVoiceListen = (delayMs = 350) => {
|
|
657
|
+
clearRestartTimer()
|
|
658
|
+
if (!voiceModeRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
|
|
659
|
+
restartTimerRef.current = window.setTimeout(() => {
|
|
660
|
+
restartTimerRef.current = null
|
|
661
|
+
startRecognition(true)
|
|
662
|
+
}, delayMs)
|
|
663
|
+
}
|
|
664
|
+
const cancelSpeech = () => {
|
|
665
|
+
speechRunRef.current += 1
|
|
666
|
+
audioRef.current?.pause()
|
|
667
|
+
audioRef.current = null
|
|
668
|
+
speakingRef.current = null
|
|
669
|
+
if (typeof window !== 'undefined' && window.speechSynthesis) window.speechSynthesis.cancel()
|
|
670
|
+
setSpeakingText(null)
|
|
671
|
+
}
|
|
672
|
+
const speakNative = (text: string, displayText: string) => {
|
|
673
|
+
if (typeof window === 'undefined' || !window.speechSynthesis) {
|
|
674
|
+
setSpeakingText(null)
|
|
675
|
+
speakingRef.current = null
|
|
676
|
+
scheduleVoiceListen(900)
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
const hasThai = /[\u0e00-\u0e7f]/.test(text)
|
|
680
|
+
const utterance = new SpeechSynthesisUtterance(text)
|
|
681
|
+
utterance.lang = hasThai ? 'th-TH' : 'en-US'
|
|
682
|
+
utterance.volume = Math.max(0, Math.min(1, numericSetting(settingsConfig?.ttsVolume, 1)))
|
|
683
|
+
utterance.rate = Math.max(0.1, Math.min(10, numericSetting(settingsConfig?.ttsSpeed, 1)))
|
|
684
|
+
utterance.pitch = Math.max(0, Math.min(2, numericSetting(settingsConfig?.ttsPitch, 1)))
|
|
685
|
+
const voice = window.speechSynthesis.getVoices().find((item) => item.lang.startsWith(hasThai ? 'th' : 'en'))
|
|
686
|
+
if (voice) utterance.voice = voice
|
|
687
|
+
const finishSpeech = () => {
|
|
688
|
+
setSpeakingText((current) => (current === displayText ? null : current))
|
|
689
|
+
speakingRef.current = null
|
|
690
|
+
scheduleVoiceListen(900)
|
|
691
|
+
}
|
|
692
|
+
utterance.onend = finishSpeech
|
|
693
|
+
utterance.onerror = finishSpeech
|
|
694
|
+
speakingRef.current = displayText
|
|
695
|
+
setSpeakingText(displayText)
|
|
696
|
+
window.speechSynthesis.speak(utterance)
|
|
697
|
+
}
|
|
698
|
+
const playGoogleTts = async (text: string, displayText: string, runId: number) => {
|
|
699
|
+
const chunks = await getTtsUrls(text)
|
|
700
|
+
if (chunks.length === 0) throw new Error('No TTS URLs available')
|
|
701
|
+
for (const chunk of chunks) {
|
|
702
|
+
if (speechRunRef.current !== runId) return
|
|
703
|
+
await new Promise<void>((resolve, reject) => {
|
|
704
|
+
const audio = new Audio(chunk.url)
|
|
705
|
+
audio.volume = Math.max(0, Math.min(1, numericSetting(settingsConfig?.ttsVolume, 1)))
|
|
706
|
+
audio.playbackRate = Math.max(0.25, Math.min(4, numericSetting(settingsConfig?.ttsSpeed, 1)))
|
|
707
|
+
audioRef.current = audio
|
|
708
|
+
audio.onended = () => resolve()
|
|
709
|
+
audio.onpause = () => resolve()
|
|
710
|
+
audio.onerror = () => reject(new Error('Google TTS playback failed'))
|
|
711
|
+
audio.play().catch(reject)
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
if (speechRunRef.current === runId) {
|
|
715
|
+
setSpeakingText((current) => (current === displayText ? null : current))
|
|
716
|
+
speakingRef.current = null
|
|
717
|
+
scheduleVoiceListen()
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const speak = (text: string) => {
|
|
721
|
+
const speechText = cleanSpeechText(text)
|
|
722
|
+
if (!speechText) return
|
|
723
|
+
if (speakingText === text) {
|
|
724
|
+
cancelSpeech()
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
cancelSpeech()
|
|
729
|
+
const runId = speechRunRef.current
|
|
730
|
+
speakingRef.current = text
|
|
731
|
+
setSpeakingText(text)
|
|
732
|
+
if (settingsConfig?.ttsProvider === 'google') {
|
|
733
|
+
playGoogleTts(speechText, text, runId).catch((error) => {
|
|
734
|
+
console.warn('Google TTS failed, falling back to native speech synthesis:', error)
|
|
735
|
+
if (speechRunRef.current === runId) speakNative(speechText, text)
|
|
736
|
+
})
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
speakNative(speechText, text)
|
|
740
|
+
}
|
|
741
|
+
const blobToDataUri = (blob: Blob) => new Promise<string>((resolve, reject) => {
|
|
742
|
+
const reader = new FileReader()
|
|
743
|
+
reader.onerror = () => reject(reader.error ?? new Error('Failed to read audio recording'))
|
|
744
|
+
reader.onload = () => resolve(String(reader.result))
|
|
745
|
+
reader.readAsDataURL(blob)
|
|
746
|
+
})
|
|
747
|
+
const preferredAudioMimeType = () => {
|
|
748
|
+
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']
|
|
749
|
+
return candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? ''
|
|
750
|
+
}
|
|
751
|
+
const startAudioRecording = async (autoSend: boolean) => {
|
|
752
|
+
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
|
753
|
+
alert('ขออภัย ระบบนี้ไม่สามารถเข้าถึงไมค์หรืออัดเสียงในเบราว์เซอร์นี้ได้')
|
|
754
|
+
voiceModeRef.current = false
|
|
755
|
+
setVoiceMode(false)
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
try {
|
|
760
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
761
|
+
const mimeType = preferredAudioMimeType()
|
|
762
|
+
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
|
763
|
+
const chunks: Blob[] = []
|
|
764
|
+
let heardVoice = false
|
|
765
|
+
let quietSince = 0
|
|
766
|
+
const startedAt = Date.now()
|
|
767
|
+
const audioContext = new AudioContext()
|
|
768
|
+
const source = audioContext.createMediaStreamSource(stream)
|
|
769
|
+
const analyser = audioContext.createAnalyser()
|
|
770
|
+
const samples = new Uint8Array(analyser.fftSize)
|
|
771
|
+
source.connect(analyser)
|
|
772
|
+
|
|
773
|
+
mediaStreamRef.current = stream
|
|
774
|
+
mediaRecorderRef.current = recorder
|
|
775
|
+
audioContextRef.current = audioContext
|
|
776
|
+
setVoiceTranscript('กำลังฟังเสียงจากไมค์')
|
|
777
|
+
|
|
778
|
+
recorder.ondataavailable = (event) => {
|
|
779
|
+
if (event.data.size > 0) chunks.push(event.data)
|
|
780
|
+
}
|
|
781
|
+
recorder.onstop = async () => {
|
|
782
|
+
clearVadTimer()
|
|
783
|
+
mediaRecorderRef.current = null
|
|
784
|
+
mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
|
|
785
|
+
mediaStreamRef.current = null
|
|
786
|
+
audioContextRef.current?.close().catch(() => {})
|
|
787
|
+
audioContextRef.current = null
|
|
788
|
+
setIsRecording(false)
|
|
789
|
+
if (!autoSend || !voiceModeRef.current || chunks.length === 0) return
|
|
790
|
+
if (!heardVoice) {
|
|
791
|
+
setVoiceTranscript('ยังไม่ได้ยินเสียงพูด')
|
|
792
|
+
scheduleVoiceListen(700)
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
|
|
796
|
+
const audioDataUri = await blobToDataUri(blob)
|
|
797
|
+
setVoiceTranscript('ส่งเสียงให้ AI แล้ว')
|
|
798
|
+
voiceAwaitingResponseRef.current = true
|
|
799
|
+
setVoiceAwaitingResponse(true)
|
|
800
|
+
onSendVoiceMessage('', audioDataUri)
|
|
801
|
+
.catch((error) => console.error('Voice audio message failed', error))
|
|
802
|
+
.finally(() => {
|
|
803
|
+
voiceAwaitingResponseRef.current = false
|
|
804
|
+
setVoiceAwaitingResponse(false)
|
|
805
|
+
scheduleVoiceListen()
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
recorder.start()
|
|
810
|
+
setIsRecording(true)
|
|
811
|
+
vadTimerRef.current = window.setInterval(() => {
|
|
812
|
+
analyser.getByteTimeDomainData(samples)
|
|
813
|
+
let peak = 0
|
|
814
|
+
for (const sample of samples) {
|
|
815
|
+
peak = Math.max(peak, Math.abs(sample - 128))
|
|
816
|
+
}
|
|
817
|
+
const now = Date.now()
|
|
818
|
+
if (peak > 12) {
|
|
819
|
+
heardVoice = true
|
|
820
|
+
quietSince = 0
|
|
821
|
+
setVoiceTranscript('กำลังฟัง...')
|
|
822
|
+
} else if (heardVoice) {
|
|
823
|
+
quietSince = quietSince || now
|
|
824
|
+
}
|
|
825
|
+
const silenceElapsed = quietSince ? now - quietSince : 0
|
|
826
|
+
const totalElapsed = now - startedAt
|
|
827
|
+
if ((heardVoice && silenceElapsed > 1300) || totalElapsed > 12000) {
|
|
828
|
+
recorder.stop()
|
|
829
|
+
}
|
|
830
|
+
}, 120)
|
|
831
|
+
} catch (error) {
|
|
832
|
+
console.error('Failed to record microphone audio', error)
|
|
833
|
+
setIsRecording(false)
|
|
834
|
+
voiceModeRef.current = false
|
|
835
|
+
setVoiceMode(false)
|
|
836
|
+
alert('เปิดไมค์ไม่สำเร็จ กรุณาตรวจสิทธิ์ microphone ของเบราว์เซอร์')
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
const startRecognition = (autoSend: boolean) => {
|
|
840
|
+
if (recognitionRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
|
|
841
|
+
|
|
842
|
+
const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition
|
|
843
|
+
if (!SpeechRecognitionApi) {
|
|
844
|
+
startAudioRecording(autoSend)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
let sentTranscript = false
|
|
849
|
+
|
|
850
|
+
try {
|
|
851
|
+
const recognition = new SpeechRecognitionApi()
|
|
852
|
+
recognition.continuous = false
|
|
853
|
+
recognition.interimResults = true
|
|
854
|
+
recognition.lang = settingsConfig?.language === 'en' ? 'en-US' : 'th-TH'
|
|
855
|
+
recognition.onstart = () => setIsRecording(true)
|
|
856
|
+
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
|
857
|
+
let interimText = ''
|
|
858
|
+
let finalText = ''
|
|
859
|
+
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
|
860
|
+
const transcript = event.results[index]?.[0]?.transcript ?? ''
|
|
861
|
+
if (event.results[index]?.isFinal) finalText += transcript
|
|
862
|
+
else interimText += transcript
|
|
863
|
+
}
|
|
864
|
+
const displayText = (finalText || interimText).trim()
|
|
865
|
+
if (displayText) setVoiceTranscript(displayText)
|
|
866
|
+
const resultText = finalText.trim()
|
|
867
|
+
if (!resultText) return
|
|
868
|
+
sentTranscript = true
|
|
869
|
+
recognition.stop()
|
|
870
|
+
setVoiceTranscript(resultText)
|
|
871
|
+
if (autoSend) {
|
|
872
|
+
voiceAwaitingResponseRef.current = true
|
|
873
|
+
setVoiceAwaitingResponse(true)
|
|
874
|
+
onSendVoiceMessage(resultText)
|
|
875
|
+
.catch((error) => console.error('Voice message failed', error))
|
|
876
|
+
.finally(() => {
|
|
877
|
+
voiceAwaitingResponseRef.current = false
|
|
878
|
+
setVoiceAwaitingResponse(false)
|
|
879
|
+
scheduleVoiceListen()
|
|
880
|
+
})
|
|
881
|
+
} else {
|
|
882
|
+
onSetMessage(message.trim() ? `${message.trimEnd()} ${resultText}` : resultText)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
recognition.onerror = (event: Event) => {
|
|
886
|
+
console.error('Speech recognition error', event)
|
|
887
|
+
setIsRecording(false)
|
|
888
|
+
}
|
|
889
|
+
recognition.onend = () => {
|
|
890
|
+
recognitionRef.current = null
|
|
891
|
+
setIsRecording(false)
|
|
892
|
+
if (autoSend && voiceModeRef.current && !sentTranscript) scheduleVoiceListen()
|
|
893
|
+
}
|
|
894
|
+
recognitionRef.current = recognition
|
|
895
|
+
recognition.start()
|
|
896
|
+
} catch (error) {
|
|
897
|
+
console.error('Failed to start speech recognition', error)
|
|
898
|
+
recognitionRef.current = null
|
|
899
|
+
setIsRecording(false)
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const toggleRecording = () => {
|
|
903
|
+
if (voiceMode) {
|
|
904
|
+
voiceModeRef.current = false
|
|
905
|
+
voiceAwaitingResponseRef.current = false
|
|
906
|
+
setVoiceMode(false)
|
|
907
|
+
setVoiceAwaitingResponse(false)
|
|
908
|
+
setVoiceTranscript('')
|
|
909
|
+
stopRecognition()
|
|
910
|
+
cancelSpeech()
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
voiceModeRef.current = true
|
|
915
|
+
setVoiceMode(true)
|
|
916
|
+
setVoiceAwaitingResponse(false)
|
|
917
|
+
setVoiceTranscript('')
|
|
918
|
+
startRecognition(true)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
useEffect(() => {
|
|
922
|
+
return () => {
|
|
923
|
+
clearRestartTimer()
|
|
924
|
+
cancelSpeech()
|
|
925
|
+
stopRecognition()
|
|
926
|
+
}
|
|
927
|
+
}, [])
|
|
928
|
+
useEffect(() => {
|
|
929
|
+
voiceModeRef.current = voiceMode
|
|
930
|
+
if (!voiceMode) {
|
|
931
|
+
clearRestartTimer()
|
|
932
|
+
setVoiceTranscript('')
|
|
933
|
+
}
|
|
934
|
+
}, [voiceMode])
|
|
935
|
+
useEffect(() => {
|
|
936
|
+
sendingRef.current = sending
|
|
937
|
+
}, [sending])
|
|
938
|
+
useEffect(() => {
|
|
939
|
+
voiceAwaitingResponseRef.current = voiceAwaitingResponse
|
|
940
|
+
}, [voiceAwaitingResponse])
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
speakingRef.current = speakingText
|
|
943
|
+
}, [speakingText])
|
|
944
|
+
useEffect(() => {
|
|
945
|
+
if (!voiceMode || sending || voiceAwaitingResponse || speakingText || isRecording) return
|
|
946
|
+
scheduleVoiceListen()
|
|
947
|
+
}, [voiceMode, sending, voiceAwaitingResponse, speakingText, isRecording])
|
|
948
|
+
useEffect(() => {
|
|
949
|
+
if (sending) submittedDuringSessionRef.current = true
|
|
950
|
+
}, [sending])
|
|
951
|
+
useEffect(() => {
|
|
952
|
+
if (interactions.length === 0) return
|
|
953
|
+
const latest = interactions[interactions.length - 1]
|
|
954
|
+
if (!historyReadyRef.current) {
|
|
955
|
+
historyReadyRef.current = true
|
|
956
|
+
if (!submittedDuringSessionRef.current) {
|
|
957
|
+
lastAutoSpokenIdRef.current = latest?.id ?? null
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (sending) return
|
|
962
|
+
if (!settingsConfig?.enableVoiceReply && !voiceMode) {
|
|
963
|
+
lastAutoSpokenIdRef.current = latest?.id ?? null
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
|
|
967
|
+
lastAutoSpokenIdRef.current = latest.id
|
|
968
|
+
speak(latest.aiText)
|
|
969
|
+
}, [interactions, sending, settingsConfig?.enableVoiceReply])
|
|
970
|
+
|
|
971
|
+
// Drag and Drop Zone Overlay
|
|
972
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
973
|
+
const dragCounter = useRef(0)
|
|
974
|
+
|
|
975
|
+
const handleDragEnter = (e: DragEvent<HTMLElement>) => {
|
|
976
|
+
e.preventDefault()
|
|
977
|
+
if (e.dataTransfer?.types?.includes('Files')) {
|
|
978
|
+
dragCounter.current++
|
|
979
|
+
setIsDragging(true)
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const handleDragLeave = (e: DragEvent<HTMLElement>) => {
|
|
984
|
+
e.preventDefault()
|
|
985
|
+
if (e.dataTransfer?.types?.includes('Files')) {
|
|
986
|
+
dragCounter.current--
|
|
987
|
+
if (dragCounter.current === 0) {
|
|
988
|
+
setIsDragging(false)
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const handleDragOver = (e: DragEvent<HTMLElement>) => {
|
|
994
|
+
e.preventDefault()
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const handleDrop = (e: DragEvent<HTMLElement>) => {
|
|
998
|
+
e.preventDefault()
|
|
999
|
+
dragCounter.current = 0
|
|
1000
|
+
setIsDragging(false)
|
|
1001
|
+
|
|
1002
|
+
const files = e.dataTransfer?.files
|
|
1003
|
+
if (files && files.length > 0) {
|
|
1004
|
+
const file = files[0]
|
|
1005
|
+
if (file.type.startsWith('image/')) {
|
|
1006
|
+
const input = document.getElementById('vision-file-input') as HTMLInputElement | null
|
|
1007
|
+
if (input) {
|
|
1008
|
+
const dt = new DataTransfer()
|
|
1009
|
+
dt.items.add(file)
|
|
1010
|
+
input.files = dt.files
|
|
1011
|
+
const event = { target: input } as ChangeEvent<HTMLInputElement>
|
|
1012
|
+
onSelectImage(event)
|
|
1013
|
+
}
|
|
1014
|
+
} else if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
|
1015
|
+
const input = document.getElementById('document-file-input') as HTMLInputElement | null
|
|
1016
|
+
if (input) {
|
|
1017
|
+
const dt = new DataTransfer()
|
|
1018
|
+
dt.items.add(file)
|
|
1019
|
+
input.files = dt.files
|
|
1020
|
+
const event = { target: input } as ChangeEvent<HTMLInputElement>
|
|
1021
|
+
onSelectDocument(event)
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const submitOnEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
1027
|
+
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
|
|
1028
|
+
event.preventDefault()
|
|
1029
|
+
event.currentTarget.form?.requestSubmit()
|
|
1030
|
+
}
|
|
1031
|
+
const handleInputKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
1032
|
+
submitOnEnter(event)
|
|
1033
|
+
}
|
|
1034
|
+
const resizeInput = (element: HTMLTextAreaElement) => {
|
|
1035
|
+
element.style.height = 'auto'
|
|
1036
|
+
element.style.height = `${Math.min(element.scrollHeight, 120)}px`
|
|
1037
|
+
}
|
|
1038
|
+
useEffect(() => {
|
|
1039
|
+
if (!toolMenuOpen) return
|
|
1040
|
+
const closeMenu = (event: MouseEvent) => {
|
|
1041
|
+
if (toolMenuRef.current?.contains(event.target as Node)) return
|
|
1042
|
+
setToolMenuOpen(false)
|
|
1043
|
+
}
|
|
1044
|
+
window.addEventListener('mousedown', closeMenu)
|
|
1045
|
+
return () => window.removeEventListener('mousedown', closeMenu)
|
|
1046
|
+
}, [toolMenuOpen])
|
|
1047
|
+
useEffect(() => {
|
|
1048
|
+
const handleWindowPaste = (event: globalThis.ClipboardEvent) => {
|
|
1049
|
+
if (!event.clipboardData) return
|
|
1050
|
+
if (onPasteImage(event.clipboardData)) {
|
|
1051
|
+
event.preventDefault()
|
|
1052
|
+
event.stopPropagation()
|
|
1053
|
+
} else {
|
|
1054
|
+
window.setTimeout(() => void onReadClipboardImage(), 0)
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
window.addEventListener('paste', handleWindowPaste, true)
|
|
1058
|
+
return () => window.removeEventListener('paste', handleWindowPaste, true)
|
|
1059
|
+
}, [onPasteImage, onReadClipboardImage])
|
|
1060
|
+
useEffect(() => {
|
|
1061
|
+
const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
1062
|
+
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'v') return
|
|
1063
|
+
window.setTimeout(() => void onReadClipboardImage(), 0)
|
|
1064
|
+
}
|
|
1065
|
+
window.addEventListener('keydown', handleWindowKeyDown, true)
|
|
1066
|
+
return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
|
|
1067
|
+
}, [onReadClipboardImage])
|
|
1068
|
+
useEffect(() => {
|
|
1069
|
+
if (message) return
|
|
1070
|
+
const input = document.getElementById('chat-input') as HTMLTextAreaElement | null
|
|
1071
|
+
if (input) input.style.height = ''
|
|
1072
|
+
}, [message])
|
|
1073
|
+
|
|
1074
|
+
const openImagePicker = () => {
|
|
1075
|
+
setToolMenuOpen(false)
|
|
1076
|
+
document.getElementById('vision-file-input')?.click()
|
|
1077
|
+
}
|
|
1078
|
+
const openDocumentPicker = () => {
|
|
1079
|
+
setToolMenuOpen(false)
|
|
1080
|
+
document.getElementById('document-file-input')?.click()
|
|
1081
|
+
}
|
|
1082
|
+
const startWebSearch = () => {
|
|
1083
|
+
setToolMenuOpen(false)
|
|
1084
|
+
onStartWebSearch()
|
|
1085
|
+
}
|
|
1086
|
+
const isEmptyChat = interactions.length === 0 && !sending && !pendingApproval
|
|
1087
|
+
const renderCompletedActivity = (interaction: any) => {
|
|
1088
|
+
const interactionId = String(interaction.id)
|
|
1089
|
+
const activityView = activitiesFrom(agentActivitySnapshots[interactionId] ?? [])
|
|
1090
|
+
if (activityView.items.length === 0) return null
|
|
1091
|
+
const isOpen = Boolean(openActivityIds[interactionId])
|
|
1092
|
+
return (
|
|
1093
|
+
<div className="agent-activity-history">
|
|
1094
|
+
<button
|
|
1095
|
+
type="button"
|
|
1096
|
+
className="agent-activity-toggle"
|
|
1097
|
+
aria-expanded={isOpen}
|
|
1098
|
+
onClick={() => setOpenActivityIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
|
|
1099
|
+
>
|
|
1100
|
+
<span>{activityView.summary}</span>
|
|
1101
|
+
<span aria-hidden="true">{isOpen ? '^' : '>'}</span>
|
|
1102
|
+
</button>
|
|
1103
|
+
{isOpen && (
|
|
1104
|
+
<div className="agent-activity-card agent-activity-card-history">
|
|
1105
|
+
{renderAgentActivityTable(activityView)}
|
|
1106
|
+
</div>
|
|
1107
|
+
)}
|
|
1108
|
+
</div>
|
|
1109
|
+
)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const renderFileChanges = (interaction: any) => {
|
|
1113
|
+
const interactionId = String(interaction.id)
|
|
1114
|
+
const progress = agentActivitySnapshots[interactionId] ?? []
|
|
1115
|
+
const changes = parseFileChangesFromProgress(progress)
|
|
1116
|
+
if (changes.length === 0) return null
|
|
1117
|
+
|
|
1118
|
+
const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0)
|
|
1119
|
+
const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0)
|
|
1120
|
+
const isOpen = Boolean(openReviewIds[interactionId])
|
|
1121
|
+
|
|
1122
|
+
return (
|
|
1123
|
+
<div className="file-changes-summary-container" style={{ marginBottom: '8px' }}>
|
|
1124
|
+
<button
|
|
1125
|
+
type="button"
|
|
1126
|
+
className="agent-activity-toggle"
|
|
1127
|
+
aria-expanded={isOpen}
|
|
1128
|
+
onClick={() => setOpenReviewIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
|
|
1129
|
+
style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontWeight: 500 }}
|
|
1130
|
+
>
|
|
1131
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '2px' }}>
|
|
1132
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
1133
|
+
<polyline points="22 4 12 14.01 9 11.01" />
|
|
1134
|
+
</svg>
|
|
1135
|
+
<span>
|
|
1136
|
+
{changes.length} {changes.length === 1 ? 'file' : 'files'} changed
|
|
1137
|
+
{totalAdditions > 0 && <span style={{ color: '#10b981', marginLeft: '6px' }}>+{totalAdditions}</span>}
|
|
1138
|
+
{totalDeletions > 0 && <span style={{ color: '#ef4444', marginLeft: '4px' }}>-{totalDeletions}</span>}
|
|
1139
|
+
</span>
|
|
1140
|
+
<span aria-hidden="true">{isOpen ? '^' : '>'}</span>
|
|
1141
|
+
</button>
|
|
1142
|
+
|
|
1143
|
+
{isOpen && (
|
|
1144
|
+
<div className="agent-activity-card" style={{ border: '1px solid rgba(16, 185, 129, 0.2)', borderRadius: '8px', padding: '10px', background: 'rgba(15, 23, 42, 0.6)', marginTop: '4px' }}>
|
|
1145
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
|
1146
|
+
{changes.map((change) => {
|
|
1147
|
+
const fileKey = `${interactionId}-${change.path}`
|
|
1148
|
+
const isDiffOpen = Boolean(openFileDiffs[fileKey])
|
|
1149
|
+
const fileName = change.path.split('/').pop() || change.path
|
|
1150
|
+
const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : ''
|
|
1151
|
+
|
|
1152
|
+
return (
|
|
1153
|
+
<div key={change.path} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)', paddingBottom: '4px' }}>
|
|
1154
|
+
<div
|
|
1155
|
+
onClick={() => setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))}
|
|
1156
|
+
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }}
|
|
1157
|
+
>
|
|
1158
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
1159
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
1160
|
+
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
|
1161
|
+
<polyline points="14 2 14 8 20 8" />
|
|
1162
|
+
</svg>
|
|
1163
|
+
<span style={{ fontSize: '0.82rem', fontWeight: 600, color: change.created ? '#10b981' : '#cbd5e1' }}>
|
|
1164
|
+
{fileName}
|
|
1165
|
+
{dirPath && <span style={{ fontSize: '0.72rem', color: '#64748b', fontWeight: 400, marginLeft: '6px' }}>{dirPath}</span>}
|
|
1166
|
+
{change.created && <span style={{ fontSize: '0.7rem', color: '#10b981', marginLeft: '6px', padding: '1px 4px', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '3px', background: 'rgba(16, 185, 129, 0.1)' }}>new</span>}
|
|
1167
|
+
</span>
|
|
1168
|
+
</div>
|
|
1169
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.76rem' }}>
|
|
1170
|
+
{change.additions > 0 && <span style={{ color: '#10b981' }}>+{change.additions}</span>}
|
|
1171
|
+
{change.deletions > 0 && <span style={{ color: '#ef4444' }}>-{change.deletions}</span>}
|
|
1172
|
+
<span style={{ color: '#64748b', transform: isDiffOpen ? 'rotate(90deg)' : 'none', display: 'inline-block', transition: 'transform 0.15s' }}>></span>
|
|
1173
|
+
</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
|
|
1176
|
+
{isDiffOpen && (
|
|
1177
|
+
<div style={{ marginTop: '6px', background: '#0b0f19', borderRadius: '6px', padding: '8px', border: '1px solid rgba(255, 255, 255, 0.08)', overflowX: 'auto', maxHeight: '300px' }}>
|
|
1178
|
+
{change.hunks.map((hunk, hIdx) => (
|
|
1179
|
+
<div key={hIdx} style={{ fontSize: '0.74rem', fontFamily: 'monospace', lineHeight: '1.4', marginBottom: hIdx < change.hunks.length - 1 ? '10px' : 0 }}>
|
|
1180
|
+
{hunk.oldText && (
|
|
1181
|
+
<div style={{ background: 'rgba(239, 68, 68, 0.12)', borderLeft: '3px solid #ef4444', padding: '4px 6px', color: '#fca5a5', whiteSpace: 'pre-wrap' }}>
|
|
1182
|
+
{hunk.oldText.split('\n').map((line, lIdx) => (
|
|
1183
|
+
<div key={lIdx}>- {line}</div>
|
|
1184
|
+
))}
|
|
1185
|
+
</div>
|
|
1186
|
+
)}
|
|
1187
|
+
{hunk.newText && (
|
|
1188
|
+
<div style={{ background: 'rgba(16, 185, 129, 0.12)', borderLeft: '3px solid #10b981', padding: '4px 6px', color: '#a7f3d0', whiteSpace: 'pre-wrap' }}>
|
|
1189
|
+
{hunk.newText.split('\n').map((line, lIdx) => (
|
|
1190
|
+
<div key={lIdx}>+ {line}</div>
|
|
1191
|
+
))}
|
|
1192
|
+
</div>
|
|
1193
|
+
)}
|
|
1194
|
+
</div>
|
|
1195
|
+
))}
|
|
1196
|
+
</div>
|
|
1197
|
+
)}
|
|
1198
|
+
</div>
|
|
1199
|
+
)
|
|
1200
|
+
})}
|
|
1201
|
+
</div>
|
|
1202
|
+
</div>
|
|
1203
|
+
)}
|
|
1204
|
+
</div>
|
|
1205
|
+
)
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const renderActiveFileChanges = () => {
|
|
1209
|
+
const changes = parseFileChangesFromProgress(agentProgress)
|
|
1210
|
+
if (changes.length === 0) return null
|
|
1211
|
+
|
|
1212
|
+
const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0)
|
|
1213
|
+
const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0)
|
|
1214
|
+
const isOpen = Boolean(openReviewIds['active-run'])
|
|
1215
|
+
|
|
1216
|
+
return (
|
|
1217
|
+
<div className="message ai-message agent-activity-message" style={{ marginTop: '4px', marginBottom: '8px' }}>
|
|
1218
|
+
<div className="agent-activity-card" style={{ border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '8px', padding: '10px', background: 'rgba(15, 23, 42, 0.6)' }}>
|
|
1219
|
+
<button
|
|
1220
|
+
type="button"
|
|
1221
|
+
className="agent-activity-toggle"
|
|
1222
|
+
aria-expanded={isOpen}
|
|
1223
|
+
onClick={() => setOpenReviewIds((current) => ({ ...current, 'active-run': !current['active-run'] }))}
|
|
1224
|
+
style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontWeight: 500, border: 0, background: 'transparent', padding: 0 }}
|
|
1225
|
+
>
|
|
1226
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '2px' }}>
|
|
1227
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
1228
|
+
<polyline points="22 4 12 14.01 9 11.01" />
|
|
1229
|
+
</svg>
|
|
1230
|
+
<span>
|
|
1231
|
+
{changes.length} {changes.length === 1 ? 'file' : 'files'} changed in this run
|
|
1232
|
+
{totalAdditions > 0 && <span style={{ color: '#10b981', marginLeft: '6px' }}>+{totalAdditions}</span>}
|
|
1233
|
+
{totalDeletions > 0 && <span style={{ color: '#ef4444', marginLeft: '4px' }}>-{totalDeletions}</span>}
|
|
1234
|
+
</span>
|
|
1235
|
+
<span aria-hidden="true">{isOpen ? '^' : '>'}</span>
|
|
1236
|
+
</button>
|
|
1237
|
+
|
|
1238
|
+
{isOpen && (
|
|
1239
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '8px' }}>
|
|
1240
|
+
{changes.map((change) => {
|
|
1241
|
+
const fileKey = `active-${change.path}`
|
|
1242
|
+
const isDiffOpen = Boolean(openFileDiffs[fileKey])
|
|
1243
|
+
const fileName = change.path.split('/').pop() || change.path
|
|
1244
|
+
const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : ''
|
|
1245
|
+
|
|
1246
|
+
return (
|
|
1247
|
+
<div key={change.path} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)', paddingBottom: '4px' }}>
|
|
1248
|
+
<div
|
|
1249
|
+
onClick={() => setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))}
|
|
1250
|
+
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }}
|
|
1251
|
+
>
|
|
1252
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
1253
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
1254
|
+
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
|
1255
|
+
<polyline points="14 2 14 8 20 8" />
|
|
1256
|
+
</svg>
|
|
1257
|
+
<span style={{ fontSize: '0.82rem', fontWeight: 600, color: change.created ? '#10b981' : '#cbd5e1' }}>
|
|
1258
|
+
{fileName}
|
|
1259
|
+
{dirPath && <span style={{ fontSize: '0.72rem', color: '#64748b', fontWeight: 400, marginLeft: '6px' }}>{dirPath}</span>}
|
|
1260
|
+
{change.created && <span style={{ fontSize: '0.7rem', color: '#10b981', marginLeft: '6px', padding: '1px 4px', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '3px', background: 'rgba(16, 185, 129, 0.1)' }}>new</span>}
|
|
1261
|
+
</span>
|
|
1262
|
+
</div>
|
|
1263
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.76rem' }}>
|
|
1264
|
+
{change.additions > 0 && <span style={{ color: '#10b981' }}>+{change.additions}</span>}
|
|
1265
|
+
{change.deletions > 0 && <span style={{ color: '#ef4444' }}>-{change.deletions}</span>}
|
|
1266
|
+
<span style={{ color: '#64748b', transform: isDiffOpen ? 'rotate(90deg)' : 'none', display: 'inline-block', transition: 'transform 0.15s' }}>></span>
|
|
1267
|
+
</div>
|
|
1268
|
+
</div>
|
|
1269
|
+
|
|
1270
|
+
{isDiffOpen && (
|
|
1271
|
+
<div style={{ marginTop: '6px', background: '#0b0f19', borderRadius: '6px', padding: '8px', border: '1px solid rgba(255, 255, 255, 0.08)', overflowX: 'auto', maxHeight: '300px' }}>
|
|
1272
|
+
{change.hunks.map((hunk, hunkIdx) => (
|
|
1273
|
+
<div key={hunkIdx} style={{ fontSize: '0.74rem', fontFamily: 'monospace', lineHeight: '1.4', marginBottom: hunkIdx < change.hunks.length - 1 ? '10px' : 0 }}>
|
|
1274
|
+
{hunk.oldText && (
|
|
1275
|
+
<div style={{ background: 'rgba(239, 68, 68, 0.12)', borderLeft: '3px solid #ef4444', padding: '4px 6px', color: '#fca5a5', whiteSpace: 'pre-wrap' }}>
|
|
1276
|
+
{hunk.oldText.split('\n').map((line, lIdx) => (
|
|
1277
|
+
<div key={lIdx}>- {line}</div>
|
|
1278
|
+
))}
|
|
1279
|
+
</div>
|
|
1280
|
+
)}
|
|
1281
|
+
{hunk.newText && (
|
|
1282
|
+
<div style={{ background: 'rgba(16, 185, 129, 0.12)', borderLeft: '3px solid #10b981', padding: '4px 6px', color: '#a7f3d0', whiteSpace: 'pre-wrap' }}>
|
|
1283
|
+
{hunk.newText.split('\n').map((line, lIdx) => (
|
|
1284
|
+
<div key={lIdx}>+ {line}</div>
|
|
1285
|
+
))}
|
|
1286
|
+
</div>
|
|
1287
|
+
)}
|
|
1288
|
+
</div>
|
|
1289
|
+
))}
|
|
1290
|
+
</div>
|
|
1291
|
+
)}
|
|
1292
|
+
</div>
|
|
1293
|
+
)
|
|
1294
|
+
})}
|
|
1295
|
+
</div>
|
|
1296
|
+
)}
|
|
1297
|
+
</div>
|
|
1298
|
+
</div>
|
|
1299
|
+
)
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return (
|
|
1303
|
+
<section
|
|
1304
|
+
className={`conversation-panel ${isEmptyChat ? 'is-empty' : ''}`}
|
|
1305
|
+
onDragEnter={handleDragEnter}
|
|
1306
|
+
onDragOver={handleDragOver}
|
|
1307
|
+
onDragLeave={handleDragLeave}
|
|
1308
|
+
onDrop={handleDrop}
|
|
1309
|
+
style={{ position: 'relative' }}
|
|
1310
|
+
>
|
|
1311
|
+
<div className="chat-header">
|
|
1312
|
+
<button
|
|
1313
|
+
className="mobile-menu-btn"
|
|
1314
|
+
type="button"
|
|
1315
|
+
onClick={onToggleMobileSidebar}
|
|
1316
|
+
aria-label="Toggle menu"
|
|
1317
|
+
>
|
|
1318
|
+
☰
|
|
1319
|
+
</button>
|
|
1320
|
+
<div className="chat-header-title">
|
|
1321
|
+
<img src="./assets/icon.png" alt="Logo" className="chat-header-logo" />
|
|
1322
|
+
<span>Agent Mint</span>
|
|
1323
|
+
</div>
|
|
1324
|
+
</div>
|
|
1325
|
+
|
|
1326
|
+
{isDragging && (
|
|
1327
|
+
<div
|
|
1328
|
+
className="drag-drop-overlay"
|
|
1329
|
+
onDragEnter={handleDragEnter}
|
|
1330
|
+
onDragLeave={handleDragLeave}
|
|
1331
|
+
onDragOver={handleDragOver}
|
|
1332
|
+
onDrop={handleDrop}
|
|
1333
|
+
style={{
|
|
1334
|
+
position: 'absolute',
|
|
1335
|
+
top: 0,
|
|
1336
|
+
left: 0,
|
|
1337
|
+
right: 0,
|
|
1338
|
+
bottom: 0,
|
|
1339
|
+
background: 'rgba(15, 23, 42, 0.82)',
|
|
1340
|
+
backdropFilter: 'blur(8px)',
|
|
1341
|
+
border: '2px dashed var(--accent)',
|
|
1342
|
+
borderRadius: '16px',
|
|
1343
|
+
margin: '12px',
|
|
1344
|
+
display: 'flex',
|
|
1345
|
+
flexDirection: 'column',
|
|
1346
|
+
alignItems: 'center',
|
|
1347
|
+
justifyContent: 'center',
|
|
1348
|
+
color: 'white',
|
|
1349
|
+
zIndex: 1000,
|
|
1350
|
+
pointerEvents: 'auto',
|
|
1351
|
+
}}
|
|
1352
|
+
>
|
|
1353
|
+
<div style={{ fontSize: '3.5rem', marginBottom: '16px' }}>🖼️</div>
|
|
1354
|
+
<div style={{ fontSize: '1.25rem', fontWeight: 'bold', letterSpacing: '0.5px' }}>วางไฟล์เพื่อแนบข้อมูล</div>
|
|
1355
|
+
<div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>รองรับรูปภาพ (PNG, JPEG, WebP, GIF) และไฟล์ PDF</div>
|
|
1356
|
+
</div>
|
|
1357
|
+
)}
|
|
1358
|
+
|
|
1359
|
+
<div className="chat-container">
|
|
1360
|
+
{interactions.map((interaction) => (
|
|
1361
|
+
<div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
|
|
1362
|
+
{interaction.isSystemEvent ? (
|
|
1363
|
+
<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' }}>
|
|
1364
|
+
{interaction.userText}
|
|
1365
|
+
</div>
|
|
1366
|
+
) : interaction.userText && (
|
|
1367
|
+
<div className="message user-message">
|
|
1368
|
+
<div className="bubble-wrapper">
|
|
1369
|
+
<div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
|
|
1370
|
+
<div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
|
|
1371
|
+
</div>
|
|
1372
|
+
</div>
|
|
1373
|
+
)}
|
|
1374
|
+
<div className="message ai-message">
|
|
1375
|
+
<div className="bubble-wrapper">
|
|
1376
|
+
{renderCompletedActivity(interaction)}
|
|
1377
|
+
{renderFileChanges(interaction)}
|
|
1378
|
+
<div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
|
|
1379
|
+
<div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
1380
|
+
<button className="provider-badge">{interaction.provider} • {interaction.model}</button>
|
|
1381
|
+
{fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
|
|
1382
|
+
<span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
1383
|
+
<button
|
|
1384
|
+
type="button"
|
|
1385
|
+
className={`tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
|
|
1386
|
+
onClick={() => speak(interaction.aiText)}
|
|
1387
|
+
style={{
|
|
1388
|
+
background: 'transparent',
|
|
1389
|
+
border: 'none',
|
|
1390
|
+
color: speakingText === interaction.aiText ? 'var(--accent)' : 'var(--text-soft)',
|
|
1391
|
+
cursor: 'pointer',
|
|
1392
|
+
fontSize: '0.85rem',
|
|
1393
|
+
padding: '2px',
|
|
1394
|
+
display: 'inline-flex',
|
|
1395
|
+
alignItems: 'center',
|
|
1396
|
+
opacity: 0.7,
|
|
1397
|
+
transition: 'all 0.2s',
|
|
1398
|
+
}}
|
|
1399
|
+
title={speakingText === interaction.aiText ? "หยุดอ่านออกเสียง" : "อ่านออกเสียง"}
|
|
1400
|
+
>
|
|
1401
|
+
{renderSpeakerIcon(speakingText === interaction.aiText)}
|
|
1402
|
+
</button>
|
|
1403
|
+
</div>
|
|
1404
|
+
</div>
|
|
1405
|
+
</div>
|
|
1406
|
+
</div>
|
|
1407
|
+
))}
|
|
1408
|
+
|
|
1409
|
+
{sending && (
|
|
1410
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
|
|
1411
|
+
<div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{sendingImageMarkers ? renderFormattedMessage(`${sendingMessage} ${sendingImageMarkers}`) : renderFormattedMessage(sendingMessage)}</div></div></div>
|
|
1412
|
+
{agentMode && agentActivities.items.length > 0 && (
|
|
1413
|
+
<div className="message ai-message agent-activity-message">
|
|
1414
|
+
<div className="agent-activity-card">
|
|
1415
|
+
<div className="agent-activity-header">
|
|
1416
|
+
<span>{agentActivities.summary}</span>
|
|
1417
|
+
<span className="agent-activity-status" data-state={pendingApproval ? 'approval' : 'active'}>
|
|
1418
|
+
{pendingApproval ? 'Waiting for approval' : 'Working'}
|
|
1419
|
+
</span>
|
|
1420
|
+
</div>
|
|
1421
|
+
{renderAgentActivityTable(agentActivities)}
|
|
1422
|
+
</div>
|
|
1423
|
+
</div>
|
|
1424
|
+
)}
|
|
1425
|
+
{renderActiveFileChanges()}
|
|
1426
|
+
<div className="message ai-message thinking-message">
|
|
1427
|
+
<div className="bubble-wrapper">
|
|
1428
|
+
<div className="message-bubble"><span>{streamedReply ? renderFormattedMessage(streamedReply) : 'Thinking...'}</span></div>
|
|
1429
|
+
{streamedResponse && (
|
|
1430
|
+
<div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
1431
|
+
<button className="provider-badge">{badge(streamedResponse.provider, streamedResponse.model)}</button>
|
|
1432
|
+
{activeFallbackNotice && <span className="provider-fallback-notice">{activeFallbackNotice}</span>}
|
|
1433
|
+
{streamedReply && (
|
|
1434
|
+
<button
|
|
1435
|
+
type="button"
|
|
1436
|
+
className={`tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
|
|
1437
|
+
onClick={() => speak(streamedReply)}
|
|
1438
|
+
style={{
|
|
1439
|
+
background: 'transparent',
|
|
1440
|
+
border: 'none',
|
|
1441
|
+
color: speakingText === streamedReply ? 'var(--accent)' : 'var(--text-soft)',
|
|
1442
|
+
cursor: 'pointer',
|
|
1443
|
+
fontSize: '0.85rem',
|
|
1444
|
+
padding: '2px',
|
|
1445
|
+
display: 'inline-flex',
|
|
1446
|
+
alignItems: 'center',
|
|
1447
|
+
opacity: 0.7,
|
|
1448
|
+
transition: 'all 0.2s',
|
|
1449
|
+
}}
|
|
1450
|
+
title={speakingText === streamedReply ? "หยุดอ่านออกเสียง" : "อ่านออกเสียง"}
|
|
1451
|
+
>
|
|
1452
|
+
{renderSpeakerIcon(speakingText === streamedReply)}
|
|
1453
|
+
</button>
|
|
1454
|
+
)}
|
|
1455
|
+
</div>
|
|
1456
|
+
)}
|
|
1457
|
+
</div>
|
|
1458
|
+
</div>
|
|
1459
|
+
</div>
|
|
1460
|
+
)}
|
|
1461
|
+
|
|
1462
|
+
<div ref={chatEnd} />
|
|
1463
|
+
</div>
|
|
1464
|
+
|
|
1465
|
+
<div className="input-area">
|
|
1466
|
+
{isEmptyChat && <div className="empty-chat-prompt">Mint Agent is ready to work</div>}
|
|
1467
|
+
<div className="smart-context-bar">
|
|
1468
|
+
<div className="smart-context-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
1469
|
+
<label className="toggle-switch">
|
|
1470
|
+
<input type="checkbox" checked={agentMode} onChange={(event) => onSetAgentMode(event.target.checked)} />
|
|
1471
|
+
<span className="slider round" />
|
|
1472
|
+
</label>
|
|
1473
|
+
<span>Agent Mode</span>
|
|
1474
|
+
</div>
|
|
1475
|
+
</div>
|
|
1476
|
+
{voiceMode && (
|
|
1477
|
+
<div className="voice-mode-bar" data-state={voiceStatus}>
|
|
1478
|
+
<span className="voice-mode-dot" />
|
|
1479
|
+
<span>{voiceStatusLabel}</span>
|
|
1480
|
+
{voiceTranscript && <span className="voice-mode-transcript">{voiceTranscript}</span>}
|
|
1481
|
+
</div>
|
|
1482
|
+
)}
|
|
1483
|
+
|
|
1484
|
+
<form
|
|
1485
|
+
id="chat-form"
|
|
1486
|
+
onSubmit={onSubmit}
|
|
1487
|
+
onPaste={(event: ClipboardEvent<HTMLElement>) => {
|
|
1488
|
+
if (onPasteImage(event.clipboardData)) event.preventDefault()
|
|
1489
|
+
}}
|
|
1490
|
+
>
|
|
1491
|
+
{(imageAttachments.length > 0 || documentName) && (
|
|
1492
|
+
<div className="mint-attachment">
|
|
1493
|
+
{imageAttachments.map((attachment, idx) => (
|
|
1494
|
+
<div className="mint-image-attachment" key={idx}>
|
|
1495
|
+
<img className="mint-image-preview" src={attachment.previewDataUri || attachment.dataUri} alt={attachment.name || 'Image attachment'} />
|
|
1496
|
+
<button className="mint-attachment-remove" type="button" onClick={() => onRemoveImage(idx)} aria-label="Remove image">×</button>
|
|
1497
|
+
</div>
|
|
1498
|
+
))}
|
|
1499
|
+
{documentName && (
|
|
1500
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
|
1501
|
+
<span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--accent)' }}>
|
|
1502
|
+
<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">
|
|
1503
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
|
1504
|
+
<polyline points="14 2 14 8 20 8"></polyline>
|
|
1505
|
+
<line x1="16" y1="13" x2="8" y2="13"></line>
|
|
1506
|
+
<line x1="16" y1="17" x2="8" y2="17"></line>
|
|
1507
|
+
</svg>
|
|
1508
|
+
</span>
|
|
1509
|
+
<span style={{ fontSize: '0.76rem', color: 'var(--text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '220px' }}>{documentName}</span>
|
|
1510
|
+
<button type="button" onClick={onRemoveDocument} style={{ background: 'transparent', border: 0, color: '#ef4444', cursor: 'pointer' }}>✕</button>
|
|
1511
|
+
</div>
|
|
1512
|
+
)}
|
|
1513
|
+
</div>
|
|
1514
|
+
)}
|
|
1515
|
+
<textarea
|
|
1516
|
+
id="chat-input"
|
|
1517
|
+
value={message}
|
|
1518
|
+
onChange={(event) => {
|
|
1519
|
+
resizeInput(event.currentTarget)
|
|
1520
|
+
onSetMessage(event.target.value)
|
|
1521
|
+
}}
|
|
1522
|
+
onKeyDown={handleInputKeyDown}
|
|
1523
|
+
placeholder="Ask anything, @ to mention, / for actions"
|
|
1524
|
+
rows={1}
|
|
1525
|
+
/>
|
|
1526
|
+
<div className="chat-tool-menu-wrap" ref={toolMenuRef}>
|
|
1527
|
+
<button id="chat-tool-btn" type="button" aria-haspopup="menu" aria-expanded={toolMenuOpen} onClick={() => setToolMenuOpen((open) => !open)} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
1528
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
1529
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
1530
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
1531
|
+
</svg>
|
|
1532
|
+
</button>
|
|
1533
|
+
{toolMenuOpen && (
|
|
1534
|
+
<div className="chat-tool-menu" role="menu">
|
|
1535
|
+
<button type="button" role="menuitem" onClick={openImagePicker}>
|
|
1536
|
+
<span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
|
|
1537
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
1538
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
1539
|
+
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
|
1540
|
+
<polyline points="21 15 16 10 5 21"></polyline>
|
|
1541
|
+
</svg>
|
|
1542
|
+
</span>
|
|
1543
|
+
<span>Add image</span>
|
|
1544
|
+
</button>
|
|
1545
|
+
<button type="button" role="menuitem" onClick={openDocumentPicker}>
|
|
1546
|
+
<span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
|
|
1547
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
1548
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
|
1549
|
+
<polyline points="14 2 14 8 20 8"></polyline>
|
|
1550
|
+
</svg>
|
|
1551
|
+
</span>
|
|
1552
|
+
<span>Add file</span>
|
|
1553
|
+
</button>
|
|
1554
|
+
<button type="button" role="menuitem" onClick={startWebSearch}>
|
|
1555
|
+
<span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
|
|
1556
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
1557
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
1558
|
+
<line x1="2" y1="12" x2="22" y2="12"></line>
|
|
1559
|
+
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
|
1560
|
+
</svg>
|
|
1561
|
+
</span>
|
|
1562
|
+
<span>Search web</span>
|
|
1563
|
+
</button>
|
|
1564
|
+
</div>
|
|
1565
|
+
)}
|
|
1566
|
+
</div>
|
|
1567
|
+
<input id="vision-file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={onSelectImage} style={{ display: 'none' }} />
|
|
1568
|
+
<input id="document-file-input" type="file" accept="application/pdf,.pdf" onChange={onSelectDocument} style={{ display: 'none' }} />
|
|
1569
|
+
<div className="chat-provider-select" style={{ display: 'flex', gap: '4px', padding: 0, background: 'transparent', border: 0, width: '100%', height: '32px' }}>
|
|
1570
|
+
<select
|
|
1571
|
+
value={status?.activeProvider ?? ''}
|
|
1572
|
+
onChange={(event) => onSetProvider(event.target.value)}
|
|
1573
|
+
style={{
|
|
1574
|
+
flex: 1,
|
|
1575
|
+
minWidth: '65px',
|
|
1576
|
+
height: '100%',
|
|
1577
|
+
padding: '0 20px 0 6px',
|
|
1578
|
+
background: 'transparent',
|
|
1579
|
+
border: 0,
|
|
1580
|
+
color: 'var(--text-soft)',
|
|
1581
|
+
fontSize: '0.78rem',
|
|
1582
|
+
outline: 'none',
|
|
1583
|
+
cursor: 'pointer',
|
|
1584
|
+
fontFamily: 'inherit',
|
|
1585
|
+
textOverflow: 'ellipsis',
|
|
1586
|
+
whiteSpace: 'nowrap'
|
|
1587
|
+
}}
|
|
1588
|
+
>
|
|
1589
|
+
{status?.availableProviders.map((provider) => {
|
|
1590
|
+
let displayName = provider
|
|
1591
|
+
if (provider === 'gemini') displayName = 'Gemini'
|
|
1592
|
+
else if (provider === 'openai') displayName = 'OpenAI'
|
|
1593
|
+
else if (provider === 'openrouter') displayName = 'OpenRouter'
|
|
1594
|
+
else if (provider === 'deepseek') displayName = 'DeepSeek'
|
|
1595
|
+
else if (provider === 'anthropic') displayName = 'Claude'
|
|
1596
|
+
else if (provider === 'huggingface') displayName = 'HF'
|
|
1597
|
+
else if (provider === 'local_openai') displayName = 'Local'
|
|
1598
|
+
else if (provider === 'ollama') displayName = 'Ollama'
|
|
1599
|
+
return <option key={provider} value={provider}>{displayName}</option>
|
|
1600
|
+
})}
|
|
1601
|
+
</select>
|
|
1602
|
+
{availableModels.length > 0 && (
|
|
1603
|
+
<select
|
|
1604
|
+
value={activeModel}
|
|
1605
|
+
onChange={(event) => onSetModel(event.target.value)}
|
|
1606
|
+
style={{
|
|
1607
|
+
flex: 1.2,
|
|
1608
|
+
minWidth: '85px',
|
|
1609
|
+
height: '100%',
|
|
1610
|
+
padding: '0 20px 0 6px',
|
|
1611
|
+
background: 'transparent',
|
|
1612
|
+
border: 0,
|
|
1613
|
+
color: 'var(--text-soft)',
|
|
1614
|
+
fontSize: '0.78rem',
|
|
1615
|
+
outline: 'none',
|
|
1616
|
+
cursor: 'pointer',
|
|
1617
|
+
fontFamily: 'inherit',
|
|
1618
|
+
textOverflow: 'ellipsis',
|
|
1619
|
+
whiteSpace: 'nowrap'
|
|
1620
|
+
}}
|
|
1621
|
+
>
|
|
1622
|
+
{availableModels.map((model) => (
|
|
1623
|
+
<option key={model} value={model}>{model.split('/').pop()}</option>
|
|
1624
|
+
))}
|
|
1625
|
+
{!availableModels.includes(activeModel) && activeModel && (
|
|
1626
|
+
<option value={activeModel}>{activeModel.split('/').pop()}</option>
|
|
1627
|
+
)}
|
|
1628
|
+
</select>
|
|
1629
|
+
)}
|
|
1630
|
+
</div>
|
|
1631
|
+
<button
|
|
1632
|
+
id="mic-btn"
|
|
1633
|
+
className={`${isRecording ? 'is-recording' : ''} ${voiceMode ? 'voice-mode-active' : ''}`}
|
|
1634
|
+
type="button"
|
|
1635
|
+
onClick={toggleRecording}
|
|
1636
|
+
title={voiceMode ? 'ปิดโหมดสนทนาเสียง' : 'เปิดโหมดสนทนาเสียง'}
|
|
1637
|
+
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
|
1638
|
+
>
|
|
1639
|
+
{isRecording ? (
|
|
1640
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
1641
|
+
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
|
|
1642
|
+
</svg>
|
|
1643
|
+
) : (
|
|
1644
|
+
<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">
|
|
1645
|
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
|
1646
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
|
1647
|
+
<line x1="12" y1="19" x2="12" y2="23"></line>
|
|
1648
|
+
<line x1="8" y1="23" x2="16" y2="23"></line>
|
|
1649
|
+
</svg>
|
|
1650
|
+
)}
|
|
1651
|
+
</button>
|
|
1652
|
+
<button id="send-btn" type="submit" disabled={sending || !canSubmit} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
1653
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
1654
|
+
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
1655
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
1656
|
+
</svg>
|
|
1657
|
+
</button>
|
|
1658
|
+
</form>
|
|
1659
|
+
</div>
|
|
1660
|
+
</section>
|
|
1661
|
+
)
|
|
1662
|
+
}
|