Compare commits
6 Commits
v0.3.3-bet
...
v0.3.3-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1074b019d3 | ||
|
|
2da0cf9421 | ||
|
|
9987a586e2 | ||
|
|
2827acfe96 | ||
|
|
afb6e77c7f | ||
|
|
84be22661b |
@@ -352,17 +352,20 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
setValidating(null)
|
setValidating(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-providers-list">
|
<div className="config-providers-list">
|
||||||
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
|
{displayed.map((p, i) => {
|
||||||
{providers.map((p, i) => {
|
|
||||||
const isEditing = editProvider === p.name
|
const isEditing = editProvider === p.name
|
||||||
const isValidationTarget = validationStatus?.provider === p.name
|
const isValidationTarget = validationStatus?.provider === p.name
|
||||||
|
const currentModel = providerForm[p.name]?.model || p.model
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="config-card provider-card-v2">
|
<div key={i} className="config-card provider-card-v2">
|
||||||
<div className="provider-card-top">
|
<div className="provider-card-top">
|
||||||
<div className="provider-card-identity">
|
<div className="provider-card-identity">
|
||||||
<span className="provider-card-name">{p.name}</span>
|
<span className="provider-card-name">{p.name.toUpperCase()}</span>
|
||||||
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
||||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
||||||
@@ -376,7 +379,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<input
|
<input
|
||||||
className="config-form-input"
|
className="config-form-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t('config.tokenPlaceholder')}
|
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
|
||||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (!isEditing) openProviderEdit(p)
|
if (!isEditing) openProviderEdit(p)
|
||||||
@@ -391,18 +394,29 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<button
|
<button
|
||||||
className="sm primary"
|
className="sm primary"
|
||||||
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
||||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
|
||||||
>
|
>
|
||||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||||
</button>
|
</button>
|
||||||
{isValidationTarget && validationStatus?.valid && (
|
{isEditing && (
|
||||||
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
||||||
{p.active && <span className="badge ok" style={{ marginRight: 6 }}>active</span>}
|
<span className="config-form-label">{t('config.model')}</span>
|
||||||
{p.model && p.model !== p.name && <span className="mono">{p.model}</span>}
|
<input
|
||||||
|
className="config-form-input"
|
||||||
|
value={currentModel || ''}
|
||||||
|
onChange={e => {
|
||||||
|
setProviderForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
[p.name]: { ...(prev[p.name] || {}), model: e.target.value },
|
||||||
|
}))
|
||||||
|
setEditProvider(p.name)
|
||||||
|
}}
|
||||||
|
placeholder="model-name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||||
const zai = (quota || []).find(p => p.name === 'zai')
|
const zai = (quota || []).find(p => p.name === 'zai')
|
||||||
|
|
||||||
|
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history']
|
||||||
|
|
||||||
|
const topCmds = (() => {
|
||||||
|
const counts = {}
|
||||||
|
for (const c of recentCmds) {
|
||||||
|
const base = c.cmd.split(/\s+/)[0]
|
||||||
|
if (EXCLUDE_CMDS.includes(base) || !base) continue
|
||||||
|
counts[base] = (counts[base] || 0) + 1
|
||||||
|
}
|
||||||
|
return Object.entries(counts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([cmd, count]) => ({ cmd, count }))
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash-grid">
|
<div className="dash-grid">
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
@@ -175,6 +190,16 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
<div className="dash-card-head">
|
<div className="dash-card-head">
|
||||||
<span className="dash-label">Recent Commands</span>
|
<span className="dash-label">Recent Commands</span>
|
||||||
</div>
|
</div>
|
||||||
|
{topCmds.length > 0 && (
|
||||||
|
<div className="dash-cmd-top">
|
||||||
|
{topCmds.map((c, i) => (
|
||||||
|
<div key={i} className="dash-cmd-chip" onClick={() => navigator.clipboard.writeText(c.cmd)} title="Copier">
|
||||||
|
<span className="dash-cmd-chip-name">{c.cmd}</span>
|
||||||
|
<span className="dash-cmd-chip-count">{c.count}×</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="dash-cmd-list">
|
<div className="dash-cmd-list">
|
||||||
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
||||||
{recentCmds.map((c, i) => (
|
{recentCmds.map((c, i) => (
|
||||||
|
|||||||
@@ -53,11 +53,9 @@ function renderContent(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatText(text) {
|
function formatText(text) {
|
||||||
// First escape HTML entities
|
|
||||||
let html = text
|
let html = text
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
// Apply markdown transformations (now with escaped brackets)
|
|
||||||
html = html
|
html = html
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||||
@@ -66,10 +64,10 @@ function formatText(text) {
|
|||||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
|
.replace(/\n/g, '<br/>')
|
||||||
|
|
||||||
// Sanitize: remove event handlers and dangerous protocols
|
|
||||||
html = html
|
html = html
|
||||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '') // Remove on* event handlers
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||||
.replace(/javascript:/gi, '')
|
.replace(/javascript:/gi, '')
|
||||||
.replace(/data:/gi, '')
|
.replace(/data:/gi, '')
|
||||||
|
|
||||||
@@ -300,6 +298,7 @@ export default function Studio({ api }) {
|
|||||||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
|
const feedRef = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
|
|
||||||
@@ -330,6 +329,20 @@ export default function Studio({ api }) {
|
|||||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
}, [messages, streaming, streamThinking, streamToolCalls])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onTab = (e) => {
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
|
||||||
|
const feed = document.querySelector('.studio-feed-layout')
|
||||||
|
if (!feed?.closest('.tab-hidden')) {
|
||||||
|
e.preventDefault()
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onTab)
|
||||||
|
return () => window.removeEventListener('keydown', onTab)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto'
|
||||||
@@ -538,10 +551,38 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model']
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSend()
|
handleSend()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
const ta = textareaRef.current
|
||||||
|
if (!ta) return
|
||||||
|
if (document.activeElement !== ta) {
|
||||||
|
ta.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const val = ta.value
|
||||||
|
const pos = ta.selectionStart
|
||||||
|
const before = val.slice(0, pos)
|
||||||
|
const afterSlash = before.match(/\/(\w*)$/)
|
||||||
|
if (afterSlash) {
|
||||||
|
const partial = afterSlash[0]
|
||||||
|
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const completed = matches[0] + ' '
|
||||||
|
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
||||||
|
setInput(newText)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,12 +628,22 @@ export default function Studio({ api }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="studio-feed-layout">
|
<div className="studio-feed-layout">
|
||||||
<div className="studio-feed">
|
<div className="studio-feed-scroll-wrap">
|
||||||
{renderMessages()}
|
<div className="studio-feed" ref={feedRef}>
|
||||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
{renderMessages()}
|
||||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||||
)}
|
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
||||||
<div ref={messagesEnd} style={{ height: '24px' }} />
|
)}
|
||||||
|
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||||
|
</div>
|
||||||
|
<div className="studio-scroll-btns">
|
||||||
|
<button className="studio-scroll-btn" onClick={() => feedRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} title="Remonter">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="studio-scroll-btn" onClick={() => messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })} title="Descendre">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="studio-input-area">
|
<div className="studio-input-area">
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
.content { flex: 1; overflow: hidden; position: relative; }
|
.content { flex: 1; overflow: hidden; position: relative; }
|
||||||
|
.content > div { height: 100%; }
|
||||||
.tab-hidden { display: none; }
|
.tab-hidden { display: none; }
|
||||||
|
|
||||||
.statusbar {
|
.statusbar {
|
||||||
@@ -594,6 +595,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.dash-grid {
|
.dash-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -684,6 +686,17 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
flex: 1; min-width: 0;
|
flex: 1; min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.dash-cmd-chip {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 12px; border-radius: var(--radius);
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
|
||||||
|
.dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
||||||
|
.dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
|
||||||
|
|
||||||
/* Services */
|
/* Services */
|
||||||
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.dash-svc-row {
|
.dash-svc-row {
|
||||||
@@ -763,7 +776,17 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
/* ── Studio Feed ── */
|
/* ── Studio Feed ── */
|
||||||
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
.studio-feed-scroll-wrap { flex: 1; position: relative; overflow: hidden; }
|
||||||
|
.studio-feed { height: 100%; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.studio-scroll-btns { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
|
||||||
|
.studio-scroll-btn {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%; padding: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.studio-scroll-btn:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); opacity: 1; }
|
||||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||||
.feed-item:hover { background: var(--bg-card); }
|
.feed-item:hover { background: var(--bg-card); }
|
||||||
|
|||||||
Reference in New Issue
Block a user