wip: agui integration
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components';
|
||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar, AssistantSidebar } from './components';
|
||||
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||
import { loadTabsForProject, saveTabsForProject } from './utils';
|
||||
import { openSingletonToolTab } from './navigation/tabPolicy';
|
||||
@@ -33,6 +33,7 @@ const App: React.FC = () => {
|
||||
setLoading,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
setActiveView,
|
||||
setSelectedPost,
|
||||
setActiveProject,
|
||||
@@ -307,6 +308,12 @@ const App: React.FC = () => {
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:toggleAssistantSidebar', () => {
|
||||
toggleAssistantSidebar();
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:viewPosts', () => {
|
||||
const state = useAppStore.getState();
|
||||
@@ -538,7 +545,7 @@ const App: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { sidebarVisible } = useAppStore();
|
||||
const { sidebarVisible, assistantSidebarVisible } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -562,6 +569,18 @@ const App: React.FC = () => {
|
||||
<Editor />
|
||||
<Panel />
|
||||
</div>
|
||||
{assistantSidebarVisible && (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
initialSize={360}
|
||||
minSize={280}
|
||||
maxSize={640}
|
||||
storageKey="assistant-sidebar-width"
|
||||
resizerPosition="start"
|
||||
>
|
||||
<AssistantSidebar />
|
||||
</ResizablePanel>
|
||||
)}
|
||||
</div>
|
||||
<StatusBar />
|
||||
<ToastContainer />
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
.assistant-panel-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-panel-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.assistant-panel-metric-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.assistant-panel-metric-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.assistant-panel-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.assistant-panel-table th,
|
||||
.assistant-panel-table td {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.assistant-panel-widget-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-panel-widget-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.assistant-panel-widget-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-panel-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-panel-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-panel-chart-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-panel-chart-type {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.assistant-panel-chart-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(48px, auto) 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-panel-chart-item progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant-panel-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-panel-form-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-panel-card {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-panel-card h4,
|
||||
.assistant-panel-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assistant-panel-card-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.assistant-panel-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-panel-image {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-panel-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.assistant-panel-image figcaption {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.assistant-panel-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-panel-tab-strip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-panel-tab-button.active {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.assistant-panel-tab-panel {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { AssistantPanelElement } from '../../navigation/assistantPanelSpec';
|
||||
import './AssistantPanelControls.css';
|
||||
|
||||
interface AssistantPanelControlsProps {
|
||||
elements: AssistantPanelElement[];
|
||||
onAction: (action: string, payload?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({ elements, onAction }) => {
|
||||
const [widgetValues, setWidgetValues] = useState<Record<string, unknown>>({});
|
||||
const [activeTabByWidget, setActiveTabByWidget] = useState<Record<string, string>>({});
|
||||
|
||||
const setWidgetValue = (key: string, value: unknown) => {
|
||||
setWidgetValues((previous) => ({
|
||||
...previous,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const getWidgetValue = (key: string, defaultValue?: unknown) =>
|
||||
Object.prototype.hasOwnProperty.call(widgetValues, key) ? widgetValues[key] : defaultValue;
|
||||
|
||||
const renderInputControl = (
|
||||
key: string,
|
||||
label: string,
|
||||
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number',
|
||||
options?: Array<{ label: string; value: string }>,
|
||||
placeholder?: string,
|
||||
defaultValue?: string | number | boolean,
|
||||
) => {
|
||||
if (inputType === 'textarea') {
|
||||
return (
|
||||
<textarea
|
||||
className="assistant-panel-widget-input chat-surface-input"
|
||||
value={String(getWidgetValue(key, defaultValue ?? ''))}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => setWidgetValue(key, event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (inputType === 'select') {
|
||||
return (
|
||||
<select
|
||||
className="assistant-panel-widget-input chat-surface-input"
|
||||
value={String(getWidgetValue(key, defaultValue ?? (options?.[0]?.value ?? '')))}
|
||||
onChange={(event) => setWidgetValue(key, event.target.value)}
|
||||
>
|
||||
{(options ?? []).map((option) => (
|
||||
<option key={`${key}-${option.value}`} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (inputType === 'checkbox') {
|
||||
return (
|
||||
<label className="assistant-panel-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(getWidgetValue(key, defaultValue ?? false))}
|
||||
onChange={(event) => setWidgetValue(key, event.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const type = inputType === 'number' ? 'number' : inputType === 'date' ? 'date' : 'text';
|
||||
return (
|
||||
<input
|
||||
className="assistant-panel-widget-input chat-surface-input"
|
||||
type={type}
|
||||
value={String(getWidgetValue(key, defaultValue ?? ''))}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => setWidgetValue(key, event.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPanelElement = (element: AssistantPanelElement, indexPath: string): React.ReactNode => {
|
||||
if (element.type === 'text') {
|
||||
return <p key={`assistant-element-${indexPath}`}>{element.text}</p>;
|
||||
}
|
||||
|
||||
if (element.type === 'metric') {
|
||||
return (
|
||||
<div key={`assistant-element-${indexPath}`} className="assistant-panel-metric">
|
||||
<span className="assistant-panel-metric-label">{element.label}</span>
|
||||
<strong className="assistant-panel-metric-value">{element.value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'list') {
|
||||
return (
|
||||
<div key={`assistant-element-${indexPath}`}>
|
||||
{element.title && <p>{element.title}</p>}
|
||||
<ul>
|
||||
{element.items.map((item, itemIndex) => <li key={`assistant-list-item-${indexPath}-${itemIndex}`}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'table') {
|
||||
return (
|
||||
<table key={`assistant-element-${indexPath}`} className="assistant-panel-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{element.columns.map((column, columnIndex) => <th key={`assistant-table-column-${indexPath}-${columnIndex}`}>{column}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{element.rows.map((row, rowIndex) => (
|
||||
<tr key={`assistant-table-row-${indexPath}-${rowIndex}`}>
|
||||
{row.map((cell, cellIndex) => <td key={`assistant-table-cell-${indexPath}-${rowIndex}-${cellIndex}`}>{cell}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'chart') {
|
||||
const maxValue = Math.max(...element.series.map((item) => item.value), 0);
|
||||
return (
|
||||
<div key={`assistant-element-${indexPath}`} className="assistant-panel-chart">
|
||||
{element.title && <p className="assistant-panel-chart-title">{element.title}</p>}
|
||||
<div className="assistant-panel-chart-type">{element.chartType}</div>
|
||||
{element.series.map((entry, seriesIndex) => (
|
||||
<div key={`assistant-chart-item-${indexPath}-${seriesIndex}`} className="assistant-panel-chart-item">
|
||||
<span>{entry.label}</span>
|
||||
<progress value={entry.value} max={maxValue || 1}></progress>
|
||||
<span>{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'input') {
|
||||
const currentValue = getWidgetValue(element.key, element.defaultValue);
|
||||
return (
|
||||
<div key={`assistant-element-${indexPath}`} className="assistant-panel-widget-block">
|
||||
{element.inputType !== 'checkbox' && <label className="assistant-panel-widget-label">{element.label}</label>}
|
||||
{renderInputControl(element.key, element.label, element.inputType, element.options, element.placeholder, element.defaultValue)}
|
||||
{element.action && element.submitLabel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue })}
|
||||
>
|
||||
{element.submitLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'datePicker') {
|
||||
const currentValue = String(getWidgetValue(element.key, element.defaultValue ?? ''));
|
||||
return (
|
||||
<div key={`assistant-element-${indexPath}`} className="assistant-panel-widget-block">
|
||||
<label className="assistant-panel-widget-label">{element.label}</label>
|
||||
<input
|
||||
className="assistant-panel-widget-input chat-surface-input"
|
||||
type="date"
|
||||
min={element.min}
|
||||
max={element.max}
|
||||
value={currentValue}
|
||||
onChange={(event) => setWidgetValue(element.key, event.target.value)}
|
||||
/>
|
||||
{element.action && element.submitLabel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue })}
|
||||
>
|
||||
{element.submitLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'form') {
|
||||
const onSubmit = () => {
|
||||
const values = element.fields.reduce<Record<string, unknown>>((accumulator, field) => {
|
||||
accumulator[field.key] = getWidgetValue(field.key, field.defaultValue);
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
onAction(element.action, {
|
||||
...(element.payload ?? {}),
|
||||
formId: element.formId,
|
||||
values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={`assistant-element-${indexPath}`} className="assistant-panel-form">
|
||||
{element.title && <p className="assistant-panel-form-title">{element.title}</p>}
|
||||
{element.fields.map((field, fieldIndex) => (
|
||||
<div key={`assistant-form-field-${indexPath}-${fieldIndex}`} className="assistant-panel-widget-block">
|
||||
{field.inputType !== 'checkbox' && <label className="assistant-panel-widget-label">{field.label}</label>}
|
||||
{renderInputControl(field.key, field.label, field.inputType, field.options, field.placeholder, field.defaultValue)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={onSubmit}>{element.submitLabel}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'card') {
|
||||
return (
|
||||
<article key={`assistant-element-${indexPath}`} className="assistant-panel-card">
|
||||
<h4>{element.title}</h4>
|
||||
{element.subtitle && <p className="assistant-panel-card-subtitle">{element.subtitle}</p>}
|
||||
<p>{element.body}</p>
|
||||
{element.actions && element.actions.length > 0 && (
|
||||
<div className="assistant-panel-card-actions">
|
||||
{element.actions.map((action, actionIndex) => (
|
||||
<button
|
||||
key={`assistant-card-action-${indexPath}-${actionIndex}`}
|
||||
type="button"
|
||||
onClick={() => onAction(action.action, action.payload)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'image') {
|
||||
return (
|
||||
<figure key={`assistant-element-${indexPath}`} className="assistant-panel-image">
|
||||
<img
|
||||
src={element.src}
|
||||
alt={element.alt || ''}
|
||||
onClick={() => {
|
||||
if (element.action) {
|
||||
onAction(element.action, element.payload);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{element.caption && <figcaption>{element.caption}</figcaption>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'tabs') {
|
||||
const widgetKey = element.widgetId || `tabs-${indexPath}`;
|
||||
const activeTabId = activeTabByWidget[widgetKey] || element.defaultTabId || element.tabs[0].id;
|
||||
const activeTab = element.tabs.find((tab) => tab.id === activeTabId) ?? element.tabs[0];
|
||||
|
||||
return (
|
||||
<div key={`assistant-element-${indexPath}`} className="assistant-panel-tabs">
|
||||
<div className="assistant-panel-tab-strip">
|
||||
{element.tabs.map((tab) => (
|
||||
<button
|
||||
key={`assistant-tab-${indexPath}-${tab.id}`}
|
||||
type="button"
|
||||
className={`assistant-panel-tab-button ${tab.id === activeTab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTabByWidget((previous) => ({ ...previous, [widgetKey]: tab.id }))}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="assistant-panel-tab-panel">
|
||||
{activeTab.elements.map((childElement, childIndex) => renderPanelElement(childElement, `${indexPath}-tab-${childIndex}`))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button key={`assistant-element-${indexPath}`} type="button" onClick={() => onAction(element.action, element.payload)}>
|
||||
{element.label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="assistant-panel-controls chat-surface-section">
|
||||
{elements.map((element, index) => renderPanelElement(element, `${index}`))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantPanelControls;
|
||||
1
src/renderer/components/AssistantPanelControls/index.ts
Normal file
1
src/renderer/components/AssistantPanelControls/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AssistantPanelControls } from './AssistantPanelControls';
|
||||
250
src/renderer/components/AssistantSidebar/AssistantSidebar.css
Normal file
250
src/renderer/components/AssistantSidebar/AssistantSidebar.css
Normal file
@@ -0,0 +1,250 @@
|
||||
.assistant-sidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
.assistant-sidebar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-sidebar-header p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.assistant-sidebar-context {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border));
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.assistant-sidebar-context-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.assistant-sidebar-context-value {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant-sidebar-prompt {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.assistant-sidebar-start-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.assistant-sidebar-error {
|
||||
margin: 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-panel-output {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-metric-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.assistant-sidebar-metric-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.assistant-sidebar-table th,
|
||||
.assistant-sidebar-table td {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.assistant-sidebar-raw-message {
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding-top: 8px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar-widget-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-widget-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.assistant-sidebar-widget-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart-type {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(48px, auto) 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-chart-item progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant-sidebar-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-form-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-sidebar-card {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-card h4,
|
||||
.assistant-sidebar-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assistant-sidebar-card-subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.assistant-sidebar-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar-image {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.assistant-sidebar-image figcaption {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.assistant-sidebar-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-sidebar-tab-strip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar-tab-button.active {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.assistant-sidebar-tab-panel {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
239
src/renderer/components/AssistantSidebar/AssistantSidebar.tsx
Normal file
239
src/renderer/components/AssistantSidebar/AssistantSidebar.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext';
|
||||
import { planAssistantRequest } from '../../navigation/assistantConversation';
|
||||
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
||||
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
|
||||
import { ensureConversationId } from '../../navigation/chatSession';
|
||||
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
||||
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
||||
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
||||
import { ChatTranscript } from '../ChatSurface';
|
||||
import { AssistantPanelControls } from '../AssistantPanelControls';
|
||||
import { useI18n } from '../../i18n';
|
||||
import '../../styles/chatSurface.css';
|
||||
import './AssistantSidebar.css';
|
||||
|
||||
export const AssistantSidebar: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const surfaceMode = getChatSurfaceMode('sidebar');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
posts,
|
||||
media,
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
openTab,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
} = useAppStore();
|
||||
const { sendMessage: sendChatMessage } = useChatMessageSender({
|
||||
chatService: window.electronAPI?.chat,
|
||||
});
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolEvents,
|
||||
beginUserTurn,
|
||||
finalizeAssistantTurn,
|
||||
appendAssistantMessage,
|
||||
stopStreaming,
|
||||
} = useChatSurfaceState();
|
||||
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
|
||||
|
||||
const editorContext = useMemo(
|
||||
() => resolveAssistantEditorContext({ activeTab, posts, media }),
|
||||
[activeTab, posts, media],
|
||||
);
|
||||
|
||||
const contextSummary = useMemo(() => {
|
||||
if (!editorContext) {
|
||||
return tr('assistantSidebar.context.none');
|
||||
}
|
||||
|
||||
const title = editorContext.title ? ` • ${editorContext.title}` : '';
|
||||
const id = editorContext.id ? ` (${editorContext.id})` : '';
|
||||
return `${editorContext.tabType}${id}${title}`;
|
||||
}, [editorContext, tr]);
|
||||
|
||||
const persistActionEvent = async (message: string) => {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI?.chat.addSystemEvent(conversationId, message);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist assistant action event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const chatService = window.electronAPI?.chat;
|
||||
if (!chatService) {
|
||||
throw new Error('Chat service unavailable');
|
||||
}
|
||||
|
||||
const resolvedConversationId = await ensureConversationId({
|
||||
currentConversationId: conversationId,
|
||||
createTitle: tr('assistantSidebar.conversationTitle'),
|
||||
chatService,
|
||||
});
|
||||
|
||||
if (!conversationId) {
|
||||
setConversationId(resolvedConversationId);
|
||||
}
|
||||
|
||||
const requestPlan = planAssistantRequest({
|
||||
conversationId,
|
||||
userPrompt: trimmed,
|
||||
context: editorContext,
|
||||
});
|
||||
|
||||
beginUserTurn(resolvedConversationId, trimmed);
|
||||
|
||||
const sendResult = await sendChatMessage({
|
||||
conversationId: resolvedConversationId,
|
||||
message: requestPlan.outboundMessage,
|
||||
metadata: { surface: 'sidebar' },
|
||||
});
|
||||
|
||||
if (!sendResult.success) {
|
||||
appendAssistantMessage(
|
||||
resolvedConversationId,
|
||||
tr('chat.errorPrefix', { error: sendResult.error || tr('chat.errorNoResponse') }),
|
||||
);
|
||||
stopStreaming();
|
||||
throw new Error(sendResult.error || 'Failed to send assistant message');
|
||||
}
|
||||
|
||||
if (sendResult.message) {
|
||||
const parsedResponse = extractAssistantResponseContent(sendResult.message);
|
||||
finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText);
|
||||
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
|
||||
} else {
|
||||
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
|
||||
stopStreaming();
|
||||
}
|
||||
|
||||
setPrompt('');
|
||||
} catch (error) {
|
||||
console.error('Failed to start assistant conversation:', error);
|
||||
setErrorMessage(tr('assistantSidebar.error.startFailed'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
|
||||
const result = dispatchAssistantAction(
|
||||
{
|
||||
action,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
openTab,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.handled) {
|
||||
setActionError(result.error || tr('assistantSidebar.error.actionFailed'));
|
||||
void persistActionEvent(
|
||||
`Assistant action failed: ${action}${result.error ? ` (${result.error})` : ''}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError(null);
|
||||
void persistActionEvent(
|
||||
`Assistant action executed: ${action}${payload ? ` ${JSON.stringify(payload)}` : ''}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="assistant-sidebar chat-surface">
|
||||
<div className="assistant-sidebar-header">
|
||||
<h3>{tr('assistantSidebar.title')}</h3>
|
||||
<p>{tr('assistantSidebar.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="assistant-sidebar-context chat-surface-section">
|
||||
<span className="assistant-sidebar-context-label">{tr('assistantSidebar.context.label')}</span>
|
||||
<span className="assistant-sidebar-context-value">{contextSummary}</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="assistant-sidebar-prompt chat-surface-input"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={tr('assistantSidebar.prompt.placeholder')}
|
||||
rows={6}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-sidebar-start-button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
{isSubmitting ? tr('assistantSidebar.button.starting') : tr('assistantSidebar.button.start')}
|
||||
</button>
|
||||
|
||||
{errorMessage && <p className="assistant-sidebar-error chat-surface-error">{errorMessage}</p>}
|
||||
|
||||
{actionError && <p className="assistant-sidebar-error chat-surface-error">{actionError}</p>}
|
||||
|
||||
{surfaceMode.showWelcomeTips && messages.length === 0 && !isStreaming && (
|
||||
<div className="assistant-sidebar-raw-message chat-surface-section">
|
||||
{tr('chat.welcomeDescription')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className="assistant-sidebar-raw-message chat-surface-section">
|
||||
<ChatTranscript
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
toolEvents={toolEvents}
|
||||
assistantRoleLabel={tr('chat.role.assistant')}
|
||||
userRoleLabel={tr('chat.role.you')}
|
||||
showToolMarkers={surfaceMode.showToolMarkers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panelElements.length > 0 && (
|
||||
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantSidebar;
|
||||
1
src/renderer/components/AssistantSidebar/index.ts
Normal file
1
src/renderer/components/AssistantSidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AssistantSidebar } from './AssistantSidebar';
|
||||
@@ -1,7 +1,15 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Markdown from 'marked-react';
|
||||
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
|
||||
import type { ChatConversation, ChatModel } from '../../types/electron';
|
||||
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
|
||||
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
|
||||
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
|
||||
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
|
||||
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
|
||||
import { useAppStore } from '../../store';
|
||||
import { ChatTranscript } from '../ChatSurface';
|
||||
import { AssistantPanelControls } from '../AssistantPanelControls';
|
||||
import { useI18n } from '../../i18n';
|
||||
import '../../styles/chatSurface.css';
|
||||
import './ChatPanel.css';
|
||||
|
||||
interface ChatPanelProps {
|
||||
@@ -10,22 +18,47 @@ interface ChatPanelProps {
|
||||
|
||||
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
const { t: tr } = useI18n();
|
||||
const surfaceMode = getChatSurfaceMode('tab');
|
||||
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [toolEvents, setToolEvents] = useState<Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>>([]);
|
||||
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||
const [needsApiKey, setNeedsApiKey] = useState(false);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [apiKeyError, setApiKeyError] = useState('');
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamingRef = useRef('');
|
||||
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
|
||||
const {
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
openTab,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
} = useAppStore();
|
||||
const { sendMessage: sendChatMessage } = useChatMessageSender({
|
||||
chatService: window.electronAPI?.chat,
|
||||
});
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolEvents,
|
||||
setMessages,
|
||||
beginUserTurn,
|
||||
appendStreamDelta,
|
||||
recordToolCall,
|
||||
recordToolResult,
|
||||
appendAssistantMessage,
|
||||
finalizeAssistantTurn,
|
||||
stopStreaming,
|
||||
abortStreaming,
|
||||
getStreamingContent,
|
||||
} = useChatSurfaceState();
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -70,8 +103,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
// Subscribe to stream events
|
||||
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
|
||||
if (data.conversationId === conversationId) {
|
||||
streamingRef.current += data.delta;
|
||||
setStreamingContent(streamingRef.current);
|
||||
appendStreamDelta(data.delta);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
@@ -80,8 +112,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
console.log('[ChatPanel] Tool call received:', data);
|
||||
if (data.conversationId === conversationId) {
|
||||
const toolCall = data.toolCall as { name: string; arguments: Record<string, unknown> };
|
||||
toolEventsRef.current.push({ name: toolCall.name, args: toolCall.arguments });
|
||||
setToolEvents(prev => [...prev, { type: 'call', name: toolCall.name, args: toolCall.arguments, timestamp: Date.now() }]);
|
||||
recordToolCall(toolCall.name, toolCall.arguments);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
@@ -90,7 +121,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
console.log('[ChatPanel] Tool result received:', data);
|
||||
if (data.conversationId === conversationId) {
|
||||
const result = data.result as { name: string; result: unknown };
|
||||
setToolEvents(prev => [...prev, { type: 'result', name: result.name, timestamp: Date.now() }]);
|
||||
recordToolResult(result.name);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
@@ -107,7 +138,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
unsubToolResult?.();
|
||||
unsubTitle?.();
|
||||
};
|
||||
}, [conversationId, loadData, scrollToBottom, checkReady]);
|
||||
}, [conversationId, loadData, scrollToBottom, checkReady, appendStreamDelta, recordToolCall, recordToolResult]);
|
||||
|
||||
// Scroll on new messages or streaming content
|
||||
useEffect(() => {
|
||||
@@ -146,76 +177,89 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
}
|
||||
setIsStreaming(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingContent('');
|
||||
setToolEvents([]);
|
||||
toolEventsRef.current = [];
|
||||
|
||||
// Add user message optimistically
|
||||
const userMessage: ChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content: message,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
beginUserTurn(conversationId, message);
|
||||
|
||||
try {
|
||||
// Send message and wait for complete response
|
||||
const result = await window.electronAPI?.chat.sendMessage(conversationId, message);
|
||||
const result = await sendChatMessage({
|
||||
conversationId,
|
||||
message,
|
||||
metadata: { surface: 'tab' },
|
||||
});
|
||||
|
||||
// Use the streamed content we accumulated via onStreamDelta
|
||||
// Fall back to the backend result message if streaming didn't capture the content
|
||||
const assistantContent = streamingRef.current || (result?.success ? result.message : '');
|
||||
const assistantContent = getStreamingContent() || (result.success ? result.message : '');
|
||||
|
||||
if (assistantContent) {
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} else if (result && !result.success) {
|
||||
const parsedResponse = extractAssistantResponseContent(assistantContent);
|
||||
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
|
||||
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
|
||||
} else if (!result.success) {
|
||||
// Backend returned an error (API failure, model unavailable, etc.)
|
||||
const errorMessage: ChatMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
|
||||
stopStreaming();
|
||||
setPanelElements([]);
|
||||
} else {
|
||||
// No content from streaming AND no error, but also no success message
|
||||
// This can happen with some models that don't return content properly
|
||||
const noContentMessage: ChatMessage = {
|
||||
id: `empty-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: tr('chat.errorEmptyResponse'),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, noContentMessage]);
|
||||
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
|
||||
stopStreaming();
|
||||
setPanelElements([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
const errorMessage: ChatMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: tr('chat.errorGeneric'),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
|
||||
stopStreaming();
|
||||
setPanelElements([]);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
if (isStreaming) {
|
||||
stopStreaming();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const persistActionEvent = async (message: string) => {
|
||||
try {
|
||||
await window.electronAPI?.chat.addSystemEvent(conversationId, message);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist chat action event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
|
||||
const result = dispatchAssistantAction(
|
||||
{
|
||||
action,
|
||||
payload,
|
||||
},
|
||||
{
|
||||
setSelectedPost,
|
||||
setSelectedMedia,
|
||||
openTab,
|
||||
setActiveView,
|
||||
toggleSidebar,
|
||||
togglePanel,
|
||||
toggleAssistantSidebar,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.handled) {
|
||||
setActionError(result.error || tr('assistantSidebar.error.actionFailed'));
|
||||
void persistActionEvent(`Assistant action failed: ${action}${result.error ? ` (${result.error})` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError(null);
|
||||
void persistActionEvent(`Assistant action executed: ${action}${payload ? ` ${JSON.stringify(payload)}` : ''}`);
|
||||
};
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.chat.setConversationModel(conversationId, modelId);
|
||||
setConversation((previous) => (previous ? { ...previous, model: modelId } : null));
|
||||
setShowModelSelector(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to change model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,140 +276,18 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
} catch (error) {
|
||||
console.error('Failed to abort:', error);
|
||||
} finally {
|
||||
// Keep any streamed content as a visible message
|
||||
const partialContent = streamingRef.current;
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
|
||||
if (partialContent) {
|
||||
const partialMessage: ChatMessage = {
|
||||
id: `partial-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: `${partialContent}\n\n*(${tr('chat.cancelledSuffix')})*`,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, partialMessage]);
|
||||
}
|
||||
abortStreaming(conversationId, tr('chat.cancelledSuffix'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.chat.setConversationModel(conversationId, modelId);
|
||||
setConversation(prev => prev ? { ...prev, model: modelId } : null);
|
||||
setShowModelSelector(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to change model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderToolMarkers = (events: Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>) => {
|
||||
if (events.length === 0) return null;
|
||||
|
||||
// Group into pairs: call + result for each tool invocation
|
||||
const markers: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
const pendingCalls = new Map<string, number>();
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'call') {
|
||||
markers.push({ name: event.name, args: event.args, completed: false });
|
||||
const count = pendingCalls.get(event.name) || 0;
|
||||
pendingCalls.set(event.name, count + 1);
|
||||
} else if (event.type === 'result') {
|
||||
// Find the last uncompleted marker for this tool
|
||||
for (let i = markers.length - 1; i >= 0; i--) {
|
||||
if (markers[i].name === event.name && !markers[i].completed) {
|
||||
markers[i].completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tool-markers">
|
||||
{markers.map((marker, i) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div key={i} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
|
||||
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessage = (msg: ChatMessage) => {
|
||||
if (msg.role === 'system' || msg.role === 'tool') return null;
|
||||
|
||||
// Parse tool calls from stored message data
|
||||
const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
if (msg.role === 'assistant' && msg.toolCalls) {
|
||||
try {
|
||||
const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args?: unknown }>;
|
||||
calls.forEach(c => storedToolCalls.push({ name: c.name, args: c.args, completed: true }));
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
||||
<div className="chat-message-avatar">
|
||||
{msg.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
|
||||
</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">
|
||||
{msg.role === 'user' ? tr('chat.role.you') : tr('chat.role.assistant')}
|
||||
</span>
|
||||
</div>
|
||||
{storedToolCalls.length > 0 && (
|
||||
<div className="tool-markers">
|
||||
{storedToolCalls.map((marker, i) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
return (
|
||||
<div key={i} className="tool-marker completed">
|
||||
<span className="tool-marker-icon">{'\u2713'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-text">
|
||||
{msg.role === 'assistant' ? (
|
||||
<Markdown gfm>{msg.content}</Markdown>
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// API key setup screen
|
||||
if (needsApiKey) {
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel chat-surface">
|
||||
<div className="chat-panel-header">
|
||||
<div className="chat-panel-title">{tr('chat.setupTitle')}</div>
|
||||
</div>
|
||||
<div className="chat-messages">
|
||||
<div className="chat-messages chat-surface-scroll">
|
||||
<div className="chat-welcome">
|
||||
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
||||
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
||||
@@ -396,37 +318,39 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel chat-surface">
|
||||
<div className="chat-panel-header">
|
||||
<div className="chat-panel-title">
|
||||
{conversation?.title || tr('chat.newChat')}
|
||||
</div>
|
||||
<div className="chat-panel-model">
|
||||
<button
|
||||
className="model-selector-button"
|
||||
onClick={() => setShowModelSelector(!showModelSelector)}
|
||||
>
|
||||
{conversation?.model || 'claude-sonnet-4'}
|
||||
<span className="model-dropdown-icon">{'\u25BE'}</span>
|
||||
</button>
|
||||
{showModelSelector && (
|
||||
<div className="model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{surfaceMode.showModelSelector && (
|
||||
<div className="chat-panel-model">
|
||||
<button
|
||||
className="model-selector-button"
|
||||
onClick={() => setShowModelSelector(!showModelSelector)}
|
||||
>
|
||||
{conversation?.model || 'claude-sonnet-4'}
|
||||
<span className="model-dropdown-icon">{'\u25BE'}</span>
|
||||
</button>
|
||||
{showModelSelector && (
|
||||
<div className="model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="chat-messages chat-surface-scroll">
|
||||
{surfaceMode.showWelcomeTips && messages.length === 0 && !isStreaming && (
|
||||
<div className="chat-welcome">
|
||||
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
||||
<h2>{tr('chat.welcomeTitle')}</h2>
|
||||
@@ -441,38 +365,22 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(renderMessage)}
|
||||
<ChatTranscript
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
toolEvents={toolEvents}
|
||||
assistantRoleLabel={tr('chat.role.assistant')}
|
||||
userRoleLabel={tr('chat.role.you')}
|
||||
showToolMarkers={surfaceMode.showToolMarkers}
|
||||
endRef={messagesEndRef}
|
||||
/>
|
||||
|
||||
{isStreaming && (streamingContent || toolEvents.length > 0) && (
|
||||
<div className="chat-message assistant streaming">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">{tr('chat.role.assistant')}</span>
|
||||
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||
</div>
|
||||
{renderToolMarkers(toolEvents)}
|
||||
{streamingContent && (
|
||||
<div className="chat-message-text">
|
||||
<Markdown gfm>{streamingContent}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{panelElements.length > 0 && (
|
||||
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} />
|
||||
)}
|
||||
|
||||
{isStreaming && !streamingContent && toolEvents.length === 0 && (
|
||||
<div className="chat-message assistant thinking">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-thinking-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
{actionError && <p className="chat-surface-error">{actionError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="chat-input-container">
|
||||
@@ -484,7 +392,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
<div className="chat-input-wrapper">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="chat-input"
|
||||
className="chat-input chat-surface-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
|
||||
154
src/renderer/components/ChatSurface/ChatTranscript.tsx
Normal file
154
src/renderer/components/ChatSurface/ChatTranscript.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import Markdown from 'marked-react';
|
||||
import type { ChatMessage } from '../../types/electron';
|
||||
import type { ChatToolEvent } from '../../navigation/useChatSurfaceState';
|
||||
|
||||
interface ChatTranscriptProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
toolEvents: ChatToolEvent[];
|
||||
assistantRoleLabel: string;
|
||||
userRoleLabel: string;
|
||||
showToolMarkers?: boolean;
|
||||
endRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolEvents,
|
||||
assistantRoleLabel,
|
||||
userRoleLabel,
|
||||
showToolMarkers = true,
|
||||
endRef,
|
||||
}) => {
|
||||
const renderToolMarkers = (events: ChatToolEvent[]) => {
|
||||
if (events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const markers: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'call') {
|
||||
markers.push({ name: event.name, args: event.args, completed: false });
|
||||
} else if (event.type === 'result') {
|
||||
for (let markerIndex = markers.length - 1; markerIndex >= 0; markerIndex -= 1) {
|
||||
if (markers[markerIndex].name === event.name && !markers[markerIndex].completed) {
|
||||
markers[markerIndex].completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tool-markers">
|
||||
{markers.map((marker, index) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([key, value]) => `${key}: ${typeof value === 'string' ? `"${value.length > 30 ? value.slice(0, 30) + '...' : value}"` : JSON.stringify(value)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div key={`${marker.name}-${index}`} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
|
||||
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessage = (message: ChatMessage) => {
|
||||
if (message.role === 'system' || message.role === 'tool') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = [];
|
||||
if (message.role === 'assistant' && message.toolCalls) {
|
||||
try {
|
||||
const parsedToolCalls = JSON.parse(message.toolCalls) as Array<{ name: string; args?: unknown }>;
|
||||
parsedToolCalls.forEach((toolCall) => storedToolCalls.push({ name: toolCall.name, args: toolCall.args, completed: true }));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={message.id} className={`chat-message ${message.role}`}>
|
||||
<div className="chat-message-avatar">
|
||||
{message.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
|
||||
</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">{message.role === 'user' ? userRoleLabel : assistantRoleLabel}</span>
|
||||
</div>
|
||||
{showToolMarkers && storedToolCalls.length > 0 && (
|
||||
<div className="tool-markers">
|
||||
{storedToolCalls.map((marker, markerIndex) => {
|
||||
const argsPreview = marker.args
|
||||
? Object.entries(marker.args as Record<string, unknown>)
|
||||
.map(([key, value]) => `${key}: ${typeof value === 'string' ? `"${value.length > 30 ? value.slice(0, 30) + '...' : value}"` : JSON.stringify(value)}`)
|
||||
.join(', ')
|
||||
: '';
|
||||
return (
|
||||
<div key={`${marker.name}-${markerIndex}`} className="tool-marker completed">
|
||||
<span className="tool-marker-icon">{'\u2713'}</span>
|
||||
<span className="tool-marker-name">{marker.name}</span>
|
||||
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-text">
|
||||
{message.role === 'assistant' ? <Markdown gfm>{message.content}</Markdown> : message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map(renderMessage)}
|
||||
|
||||
{isStreaming && (streamingContent || (showToolMarkers && toolEvents.length > 0)) && (
|
||||
<div className="chat-message assistant streaming">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">{assistantRoleLabel}</span>
|
||||
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||
</div>
|
||||
{showToolMarkers ? renderToolMarkers(toolEvents) : null}
|
||||
{streamingContent && (
|
||||
<div className="chat-message-text">
|
||||
<Markdown gfm>{streamingContent}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && !streamingContent && (!showToolMarkers || toolEvents.length === 0) && (
|
||||
<div className="chat-message assistant thinking">
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-thinking-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={endRef} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/ChatSurface/index.ts
Normal file
1
src/renderer/components/ChatSurface/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ChatTranscript } from './ChatTranscript';
|
||||
@@ -236,6 +236,41 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.window-titlebar-assistant-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window-titlebar-assistant-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 66.6667%;
|
||||
width: 1.5px;
|
||||
transform: translateX(-50%);
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
.window-titlebar-assistant-pane {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 33.3333%;
|
||||
height: 100%;
|
||||
background-color: currentColor;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.window-titlebar-assistant-icon.is-inactive .window-titlebar-assistant-pane {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.window-titlebar-action-button:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type WindowControlsOverlayLike = {
|
||||
|
||||
export const WindowTitleBar: React.FC = () => {
|
||||
const { language, t } = useI18n();
|
||||
const { sidebarVisible, panelVisible, toggleSidebar, togglePanel } = useAppStore();
|
||||
const { sidebarVisible, panelVisible, assistantSidebarVisible, toggleSidebar, togglePanel, toggleAssistantSidebar } = useAppStore();
|
||||
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
||||
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
|
||||
const [showMnemonics, setShowMnemonics] = useState<boolean>(false);
|
||||
@@ -456,6 +456,20 @@ export const WindowTitleBar: React.FC = () => {
|
||||
<span className="window-titlebar-panel-pane" data-shape="bottom-half" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="window-titlebar-action-button"
|
||||
aria-label={t('windowTitleBar.toggleAssistantSidebar')}
|
||||
onClick={toggleAssistantSidebar}
|
||||
title={assistantSidebarVisible ? t('windowTitleBar.hideAssistantSidebar') : t('windowTitleBar.showAssistantSidebar')}
|
||||
>
|
||||
<span
|
||||
className={`window-titlebar-assistant-icon ${assistantSidebarVisible ? 'is-active' : 'is-inactive'}`}
|
||||
data-shape="frame-square"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="window-titlebar-assistant-pane" data-shape="right-half" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!isMac && openMenu && activeMenu && (
|
||||
<div
|
||||
|
||||
@@ -27,3 +27,5 @@ export { WindowTitleBar } from './WindowTitleBar';
|
||||
export { DocumentationView } from './DocumentationView/DocumentationView';
|
||||
export { SiteValidationView } from './SiteValidationView';
|
||||
export { ScriptsView } from './ScriptsView/ScriptsView';
|
||||
export { AssistantSidebar } from './AssistantSidebar';
|
||||
export { AssistantPanelControls } from './AssistantPanelControls';
|
||||
|
||||
@@ -830,6 +830,19 @@
|
||||
"windowTitleBar.togglePanel": "Panel umschalten",
|
||||
"windowTitleBar.hidePanel": "Panel ausblenden (Ctrl+J)",
|
||||
"windowTitleBar.showPanel": "Panel anzeigen (Ctrl+J)",
|
||||
"windowTitleBar.toggleAssistantSidebar": "Assistenz-Seitenleiste umschalten",
|
||||
"windowTitleBar.hideAssistantSidebar": "Assistenz-Seitenleiste ausblenden (Ctrl+\\)",
|
||||
"windowTitleBar.showAssistantSidebar": "Assistenz-Seitenleiste anzeigen (Ctrl+\\)",
|
||||
"assistantSidebar.title": "KI-Assistent",
|
||||
"assistantSidebar.description": "Starten Sie mit einem gezielten Prompt inklusive aktuellem Editor-Kontext.",
|
||||
"assistantSidebar.context.label": "Aktueller Kontext",
|
||||
"assistantSidebar.context.none": "Kein aktiver Editor-Kontext",
|
||||
"assistantSidebar.prompt.placeholder": "Fragen Sie den Assistenten nach Analyse oder Abfragen Ihres aktuellen Stands…",
|
||||
"assistantSidebar.button.start": "Mit Kontext starten",
|
||||
"assistantSidebar.button.starting": "Startet…",
|
||||
"assistantSidebar.conversationTitle": "Assistent-Sitzung",
|
||||
"assistantSidebar.error.startFailed": "Assistent-Sitzung konnte nicht gestartet werden",
|
||||
"assistantSidebar.error.actionFailed": "Assistent-Aktion konnte nicht ausgeführt werden",
|
||||
"tagInput.alreadyAdded": "Tag bereits hinzugefügt",
|
||||
"tagInput.remove": "{tag} entfernen",
|
||||
"tagInput.createdTag": "Tag \"{name}\" erstellt",
|
||||
|
||||
@@ -830,6 +830,19 @@
|
||||
"windowTitleBar.togglePanel": "Toggle Panel",
|
||||
"windowTitleBar.hidePanel": "Hide Panel (Ctrl+J)",
|
||||
"windowTitleBar.showPanel": "Show Panel (Ctrl+J)",
|
||||
"windowTitleBar.toggleAssistantSidebar": "Toggle Assistant Sidebar",
|
||||
"windowTitleBar.hideAssistantSidebar": "Hide Assistant Sidebar (Ctrl+\\)",
|
||||
"windowTitleBar.showAssistantSidebar": "Show Assistant Sidebar (Ctrl+\\)",
|
||||
"assistantSidebar.title": "AI Assistant",
|
||||
"assistantSidebar.description": "Start with a focused prompt and include your current editor context.",
|
||||
"assistantSidebar.context.label": "Current context",
|
||||
"assistantSidebar.context.none": "No active editor context",
|
||||
"assistantSidebar.prompt.placeholder": "Ask the assistant to analyze or query your current work…",
|
||||
"assistantSidebar.button.start": "Start with context",
|
||||
"assistantSidebar.button.starting": "Starting…",
|
||||
"assistantSidebar.conversationTitle": "Assistant Session",
|
||||
"assistantSidebar.error.startFailed": "Failed to start assistant session",
|
||||
"assistantSidebar.error.actionFailed": "Assistant action could not be executed",
|
||||
"tagInput.alreadyAdded": "Tag already added",
|
||||
"tagInput.remove": "Remove {tag}",
|
||||
"tagInput.createdTag": "Tag \"{name}\" created",
|
||||
|
||||
@@ -830,6 +830,19 @@
|
||||
"windowTitleBar.togglePanel": "Alternar panel",
|
||||
"windowTitleBar.hidePanel": "Ocultar panel",
|
||||
"windowTitleBar.showPanel": "Mostrar panel",
|
||||
"windowTitleBar.toggleAssistantSidebar": "Alternar barra del asistente",
|
||||
"windowTitleBar.hideAssistantSidebar": "Ocultar barra del asistente (Ctrl+\\)",
|
||||
"windowTitleBar.showAssistantSidebar": "Mostrar barra del asistente (Ctrl+\\)",
|
||||
"assistantSidebar.title": "Asistente IA",
|
||||
"assistantSidebar.description": "Comienza con un prompt enfocado y enriquecido con el contexto actual del editor.",
|
||||
"assistantSidebar.context.label": "Contexto actual",
|
||||
"assistantSidebar.context.none": "Sin contexto de editor activo",
|
||||
"assistantSidebar.prompt.placeholder": "Pide al asistente analizar o consultar tu trabajo actual…",
|
||||
"assistantSidebar.button.start": "Iniciar con contexto",
|
||||
"assistantSidebar.button.starting": "Iniciando…",
|
||||
"assistantSidebar.conversationTitle": "Sesión de asistente",
|
||||
"assistantSidebar.error.startFailed": "No se pudo iniciar la sesión del asistente",
|
||||
"assistantSidebar.error.actionFailed": "No se pudo ejecutar la acción del asistente",
|
||||
"tagInput.alreadyAdded": "La etiqueta “{tag}” ya está añadida",
|
||||
"tagInput.remove": "Quitar",
|
||||
"tagInput.createdTag": "Etiqueta “{tag}” creada",
|
||||
|
||||
@@ -830,6 +830,19 @@
|
||||
"windowTitleBar.togglePanel": "Basculer le panneau",
|
||||
"windowTitleBar.hidePanel": "Masquer le panneau",
|
||||
"windowTitleBar.showPanel": "Afficher le panneau",
|
||||
"windowTitleBar.toggleAssistantSidebar": "Basculer le panneau Assistant",
|
||||
"windowTitleBar.hideAssistantSidebar": "Masquer le panneau Assistant (Ctrl+\\)",
|
||||
"windowTitleBar.showAssistantSidebar": "Afficher le panneau Assistant (Ctrl+\\)",
|
||||
"assistantSidebar.title": "Assistant IA",
|
||||
"assistantSidebar.description": "Commencez avec une requête ciblée enrichie du contexte éditeur actuel.",
|
||||
"assistantSidebar.context.label": "Contexte actuel",
|
||||
"assistantSidebar.context.none": "Aucun contexte éditeur actif",
|
||||
"assistantSidebar.prompt.placeholder": "Demandez à l’assistant d’analyser ou d’interroger votre travail en cours…",
|
||||
"assistantSidebar.button.start": "Démarrer avec contexte",
|
||||
"assistantSidebar.button.starting": "Démarrage…",
|
||||
"assistantSidebar.conversationTitle": "Session Assistant",
|
||||
"assistantSidebar.error.startFailed": "Impossible de démarrer la session assistant",
|
||||
"assistantSidebar.error.actionFailed": "L’action assistant n’a pas pu être exécutée",
|
||||
"tagInput.alreadyAdded": "Le tag « {tag} » est déjà ajouté",
|
||||
"tagInput.remove": "Supprimer",
|
||||
"tagInput.createdTag": "Tag « {tag} » créé",
|
||||
|
||||
@@ -830,6 +830,19 @@
|
||||
"windowTitleBar.togglePanel": "Mostra/Nascondi pannello",
|
||||
"windowTitleBar.hidePanel": "Nascondi pannello",
|
||||
"windowTitleBar.showPanel": "Mostra pannello",
|
||||
"windowTitleBar.toggleAssistantSidebar": "Mostra/Nascondi barra assistente",
|
||||
"windowTitleBar.hideAssistantSidebar": "Nascondi barra assistente (Ctrl+\\)",
|
||||
"windowTitleBar.showAssistantSidebar": "Mostra barra assistente (Ctrl+\\)",
|
||||
"assistantSidebar.title": "Assistente IA",
|
||||
"assistantSidebar.description": "Inizia con un prompt mirato arricchito dal contesto editor corrente.",
|
||||
"assistantSidebar.context.label": "Contesto attuale",
|
||||
"assistantSidebar.context.none": "Nessun contesto editor attivo",
|
||||
"assistantSidebar.prompt.placeholder": "Chiedi all’assistente di analizzare o interrogare il lavoro corrente…",
|
||||
"assistantSidebar.button.start": "Avvia con contesto",
|
||||
"assistantSidebar.button.starting": "Avvio…",
|
||||
"assistantSidebar.conversationTitle": "Sessione assistente",
|
||||
"assistantSidebar.error.startFailed": "Impossibile avviare la sessione assistente",
|
||||
"assistantSidebar.error.actionFailed": "Impossibile eseguire l’azione dell’assistente",
|
||||
"tagInput.alreadyAdded": "Il tag “{tag}” è già stato aggiunto",
|
||||
"tagInput.remove": "Rimuovi",
|
||||
"tagInput.createdTag": "Tag “{tag}” creato",
|
||||
|
||||
129
src/renderer/navigation/assistantActionDispatcher.ts
Normal file
129
src/renderer/navigation/assistantActionDispatcher.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { SidebarView } from './sidebarViewRegistry';
|
||||
import type { TabType } from '../store/appStore';
|
||||
import { isSidebarView } from './sidebarViewRegistry';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface AssistantActionInput {
|
||||
action: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AssistantActionDependencies {
|
||||
setSelectedPost: (id: string | null) => void;
|
||||
setSelectedMedia: (id: string | null) => void;
|
||||
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
|
||||
setActiveView: (view: SidebarView) => void;
|
||||
toggleSidebar: () => void;
|
||||
togglePanel: () => void;
|
||||
toggleAssistantSidebar: () => void;
|
||||
}
|
||||
|
||||
export interface AssistantActionResult {
|
||||
handled: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const openPostPayloadSchema = z.object({
|
||||
postId: z.string().min(1),
|
||||
});
|
||||
|
||||
const openMediaPayloadSchema = z.object({
|
||||
mediaId: z.string().min(1),
|
||||
});
|
||||
|
||||
const switchViewPayloadSchema = z
|
||||
.object({
|
||||
view: z.string().min(1),
|
||||
})
|
||||
.refine((payload) => isSidebarView(payload.view), {
|
||||
message: 'view must be a valid sidebar view',
|
||||
});
|
||||
|
||||
const openChatPayloadSchema = z.object({
|
||||
conversationId: z.string().min(1),
|
||||
});
|
||||
|
||||
function invalidPayloadError(action: string): AssistantActionResult {
|
||||
return {
|
||||
handled: false,
|
||||
error: `Invalid payload for ${action} action`,
|
||||
};
|
||||
}
|
||||
|
||||
export function dispatchAssistantAction(
|
||||
input: AssistantActionInput,
|
||||
dependencies: AssistantActionDependencies,
|
||||
): AssistantActionResult {
|
||||
const payload = input.payload ?? {};
|
||||
|
||||
if (input.action === 'openPost') {
|
||||
const parsed = openPostPayloadSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return invalidPayloadError('openPost');
|
||||
}
|
||||
const { postId } = parsed.data;
|
||||
|
||||
dependencies.setActiveView('posts');
|
||||
dependencies.setSelectedPost(postId);
|
||||
dependencies.openTab({ type: 'post', id: postId, isTransient: false });
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (input.action === 'openMedia') {
|
||||
const parsed = openMediaPayloadSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return invalidPayloadError('openMedia');
|
||||
}
|
||||
const { mediaId } = parsed.data;
|
||||
|
||||
dependencies.setActiveView('media');
|
||||
dependencies.setSelectedMedia(mediaId);
|
||||
dependencies.openTab({ type: 'media', id: mediaId, isTransient: false });
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (input.action === 'switchView') {
|
||||
const parsed = switchViewPayloadSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return invalidPayloadError('switchView');
|
||||
}
|
||||
const { view } = parsed.data;
|
||||
|
||||
dependencies.setActiveView(view as SidebarView);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (input.action === 'openChat') {
|
||||
const parsed = openChatPayloadSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return invalidPayloadError('openChat');
|
||||
}
|
||||
|
||||
dependencies.setActiveView('chat');
|
||||
dependencies.openTab({ type: 'chat', id: parsed.data.conversationId, isTransient: false });
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (input.action === 'openSettings') {
|
||||
dependencies.setActiveView('settings');
|
||||
dependencies.openTab({ type: 'settings', id: 'settings', isTransient: false });
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (input.action === 'toggleSidebar') {
|
||||
dependencies.toggleSidebar();
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (input.action === 'togglePanel') {
|
||||
dependencies.togglePanel();
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (input.action === 'toggleAssistantSidebar') {
|
||||
dependencies.toggleAssistantSidebar();
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
return { handled: false, error: `Unsupported action: ${input.action}` };
|
||||
}
|
||||
30
src/renderer/navigation/assistantConversation.ts
Normal file
30
src/renderer/navigation/assistantConversation.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { AssistantEditorContext } from './assistantPromptContext';
|
||||
import { buildAssistantStartPrompt } from './assistantPromptContext';
|
||||
|
||||
export interface AssistantRequestPlan {
|
||||
shouldCreateConversation: boolean;
|
||||
outboundMessage: string;
|
||||
}
|
||||
|
||||
export function planAssistantRequest(input: {
|
||||
conversationId: string | null;
|
||||
userPrompt: string;
|
||||
context: AssistantEditorContext | null;
|
||||
}): AssistantRequestPlan {
|
||||
const userPrompt = input.userPrompt.trim();
|
||||
|
||||
if (input.conversationId) {
|
||||
return {
|
||||
shouldCreateConversation: false,
|
||||
outboundMessage: userPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldCreateConversation: true,
|
||||
outboundMessage: buildAssistantStartPrompt({
|
||||
userPrompt,
|
||||
context: input.context,
|
||||
}),
|
||||
};
|
||||
}
|
||||
391
src/renderer/navigation/assistantPanelSpec.ts
Normal file
391
src/renderer/navigation/assistantPanelSpec.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const textElementSchema = z.object({
|
||||
type: z.literal('text'),
|
||||
text: z.string().min(1),
|
||||
});
|
||||
|
||||
const metricElementSchema = z.object({
|
||||
type: z.literal('metric'),
|
||||
label: z.string().min(1),
|
||||
value: z.string().min(1),
|
||||
});
|
||||
|
||||
const listElementSchema = z.object({
|
||||
type: z.literal('list'),
|
||||
title: z.string().optional(),
|
||||
items: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
|
||||
const tableElementSchema = z.object({
|
||||
type: z.literal('table'),
|
||||
columns: z.array(z.string().min(1)).min(1),
|
||||
rows: z.array(z.array(z.string())).min(1),
|
||||
});
|
||||
|
||||
const actionElementSchema = z.object({
|
||||
type: z.literal('action'),
|
||||
label: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const chartElementSchema = z.object({
|
||||
type: z.literal('chart'),
|
||||
chartType: z.enum(['bar', 'line', 'pie']),
|
||||
title: z.string().min(1).optional(),
|
||||
series: z.array(
|
||||
z.object({
|
||||
label: z.string().min(1),
|
||||
value: z.number(),
|
||||
}),
|
||||
).min(1),
|
||||
});
|
||||
|
||||
const inputTypeSchema = z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']);
|
||||
|
||||
const inputOptionSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const inputElementSchema = z.object({
|
||||
type: z.literal('input'),
|
||||
key: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
inputType: inputTypeSchema,
|
||||
placeholder: z.string().optional(),
|
||||
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
||||
options: z.array(inputOptionSchema).optional(),
|
||||
action: z.string().min(1).optional(),
|
||||
submitLabel: z.string().min(1).optional(),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const datePickerElementSchema = z.object({
|
||||
type: z.literal('datePicker'),
|
||||
key: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
defaultValue: z.string().optional(),
|
||||
min: z.string().optional(),
|
||||
max: z.string().optional(),
|
||||
action: z.string().min(1).optional(),
|
||||
submitLabel: z.string().min(1).optional(),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const formFieldSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
inputType: inputTypeSchema,
|
||||
placeholder: z.string().optional(),
|
||||
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
||||
options: z.array(inputOptionSchema).optional(),
|
||||
required: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const formElementSchema = z.object({
|
||||
type: z.literal('form'),
|
||||
formId: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
submitLabel: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
fields: z.array(formFieldSchema).min(1),
|
||||
});
|
||||
|
||||
const cardActionSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const cardElementSchema = z.object({
|
||||
type: z.literal('card'),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
subtitle: z.string().optional(),
|
||||
actions: z.array(cardActionSchema).optional(),
|
||||
});
|
||||
|
||||
const imageElementSchema = z.object({
|
||||
type: z.literal('image'),
|
||||
src: z.string().min(1),
|
||||
alt: z.string().optional(),
|
||||
caption: z.string().optional(),
|
||||
action: z.string().min(1).optional(),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
let assistantPanelElementSchemaRef: z.ZodTypeAny;
|
||||
|
||||
const tabsElementSchema: z.ZodTypeAny = z.lazy(() => z.object({
|
||||
type: z.literal('tabs'),
|
||||
widgetId: z.string().min(1).optional(),
|
||||
defaultTabId: z.string().min(1).optional(),
|
||||
tabs: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
elements: z.array(assistantPanelElementSchemaRef).min(1),
|
||||
}),
|
||||
).min(1),
|
||||
}));
|
||||
|
||||
assistantPanelElementSchemaRef = z.discriminatedUnion('type', [
|
||||
textElementSchema,
|
||||
metricElementSchema,
|
||||
listElementSchema,
|
||||
tableElementSchema,
|
||||
actionElementSchema,
|
||||
chartElementSchema,
|
||||
inputElementSchema,
|
||||
formElementSchema,
|
||||
datePickerElementSchema,
|
||||
cardElementSchema,
|
||||
imageElementSchema,
|
||||
tabsElementSchema,
|
||||
]);
|
||||
|
||||
export const assistantPanelElementSchema = assistantPanelElementSchemaRef;
|
||||
|
||||
export const assistantPanelSpecSchema = z.object({
|
||||
specVersion: z.literal('1'),
|
||||
elements: z.array(assistantPanelElementSchema).min(1),
|
||||
});
|
||||
|
||||
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
|
||||
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
|
||||
|
||||
export interface AssistantResponseContent {
|
||||
displayText: string;
|
||||
panelSpec: AssistantPanelSpec | null;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const normalized: Record<string, unknown> = {
|
||||
...record,
|
||||
};
|
||||
|
||||
const dataRecord = toRecord(record.data);
|
||||
if (Array.isArray(record.series)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (!dataRecord) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const labels = Array.isArray(dataRecord.labels) ? dataRecord.labels : [];
|
||||
const datasets = Array.isArray(dataRecord.datasets) ? dataRecord.datasets : [];
|
||||
const firstDataset = toRecord(datasets[0]);
|
||||
const values = Array.isArray(firstDataset?.data) ? firstDataset?.data : [];
|
||||
|
||||
if (labels.length === 0 || values.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const series = labels
|
||||
.map((label, index) => ({
|
||||
label: String(label),
|
||||
value: Number(values[index]),
|
||||
}))
|
||||
.filter((entry) => Number.isFinite(entry.value));
|
||||
|
||||
if (series.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
normalized.series = series;
|
||||
delete normalized.data;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTabContent(tabValue: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(tabValue)) {
|
||||
return tabValue.map((entry) => normalizeElement(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry));
|
||||
}
|
||||
|
||||
const normalized = normalizeElement(tabValue);
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const tabs = Array.isArray(record.tabs) ? record.tabs : [];
|
||||
const normalizedTabs = tabs
|
||||
.map((tabValue, tabIndex) => {
|
||||
const tabRecord = toRecord(tabValue);
|
||||
if (!tabRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof tabRecord.id === 'string' && tabRecord.id.trim().length > 0
|
||||
? tabRecord.id
|
||||
: `tab-${tabIndex + 1}`;
|
||||
|
||||
const label = typeof tabRecord.label === 'string' && tabRecord.label.trim().length > 0
|
||||
? tabRecord.label
|
||||
: typeof tabRecord.title === 'string' && tabRecord.title.trim().length > 0
|
||||
? tabRecord.title
|
||||
: id;
|
||||
|
||||
const elements = Array.isArray(tabRecord.elements)
|
||||
? normalizeTabContent(tabRecord.elements)
|
||||
: normalizeTabContent(tabRecord.content);
|
||||
|
||||
if (elements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
elements,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is { id: string; label: string; elements: Record<string, unknown>[] } => Boolean(entry));
|
||||
|
||||
if (normalizedTabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
tabs: normalizedTabs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeElement(value: unknown): Record<string, unknown> | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = typeof record.type === 'string' ? record.type : '';
|
||||
if (type === 'markdown') {
|
||||
const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : '';
|
||||
if (!textValue.trim()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
text: textValue,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'chart') {
|
||||
return normalizeChartElement(record);
|
||||
}
|
||||
|
||||
if (type === 'tabs') {
|
||||
return normalizeTabsElement(record);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
|
||||
const canonicalResult = assistantPanelSpecSchema.safeParse(parsed);
|
||||
if (canonicalResult.success) {
|
||||
return canonicalResult.data;
|
||||
}
|
||||
|
||||
const record = toRecord(parsed);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (record.type === 'tab' && record.content) {
|
||||
return normalizeCandidate(record.content);
|
||||
}
|
||||
|
||||
if (record.type === 'tabs') {
|
||||
const tabsElement = normalizeTabsElement(record);
|
||||
if (!tabsElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asSpec = {
|
||||
specVersion: '1',
|
||||
elements: [tabsElement],
|
||||
};
|
||||
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
|
||||
return normalizedResult.success ? normalizedResult.data : null;
|
||||
}
|
||||
|
||||
if (Array.isArray(record.elements)) {
|
||||
const normalizedElements = record.elements
|
||||
.map((element) => normalizeElement(element))
|
||||
.filter((element): element is Record<string, unknown> => Boolean(element));
|
||||
|
||||
if (normalizedElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asSpec = {
|
||||
specVersion: '1',
|
||||
elements: normalizedElements,
|
||||
};
|
||||
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
|
||||
return normalizedResult.success ? normalizedResult.data : null;
|
||||
}
|
||||
|
||||
const normalizedElement = normalizeElement(record);
|
||||
if (!normalizedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asSpec = {
|
||||
specVersion: '1',
|
||||
elements: [normalizedElement],
|
||||
};
|
||||
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
|
||||
return normalizedResult.success ? normalizedResult.data : null;
|
||||
}
|
||||
|
||||
function parseSpecCandidate(raw: string): AssistantPanelSpec | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return normalizeCandidate(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractAssistantPanelSpec(message: string): AssistantPanelSpec | null {
|
||||
return extractAssistantResponseContent(message).panelSpec;
|
||||
}
|
||||
|
||||
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
|
||||
const trimmed = message.trim();
|
||||
|
||||
const fencedMatches = [...trimmed.matchAll(/```(json)?\s*([\s\S]*?)```/gi)];
|
||||
for (const match of fencedMatches) {
|
||||
const candidate = match[2]?.trim();
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseSpecCandidate(candidate);
|
||||
if (parsed) {
|
||||
const displayText = trimmed.replace(match[0], '').trim();
|
||||
return {
|
||||
displayText,
|
||||
panelSpec: parsed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parsedWholeMessage = parseSpecCandidate(trimmed);
|
||||
return {
|
||||
displayText: parsedWholeMessage ? '' : trimmed,
|
||||
panelSpec: parsedWholeMessage,
|
||||
};
|
||||
}
|
||||
67
src/renderer/navigation/assistantPromptContext.ts
Normal file
67
src/renderer/navigation/assistantPromptContext.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { MediaData, PostData, Tab } from '../store/appStore';
|
||||
|
||||
export interface AssistantEditorContext {
|
||||
tabType: Tab['type'] | 'none';
|
||||
id?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function resolveAssistantEditorContext(input: {
|
||||
activeTab: Tab | null;
|
||||
posts: PostData[];
|
||||
media: MediaData[];
|
||||
}): AssistantEditorContext | null {
|
||||
const { activeTab, posts, media } = input;
|
||||
if (!activeTab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeTab.type === 'post') {
|
||||
const currentPost = posts.find((post) => post.id === activeTab.id);
|
||||
return {
|
||||
tabType: 'post',
|
||||
id: activeTab.id,
|
||||
title: currentPost?.title,
|
||||
};
|
||||
}
|
||||
|
||||
if (activeTab.type === 'media') {
|
||||
const currentMedia = media.find((item) => item.id === activeTab.id);
|
||||
return {
|
||||
tabType: 'media',
|
||||
id: activeTab.id,
|
||||
title: currentMedia?.originalName || currentMedia?.filename || currentMedia?.title,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tabType: activeTab.type,
|
||||
id: activeTab.id,
|
||||
title: activeTab.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssistantStartPrompt(input: {
|
||||
userPrompt: string;
|
||||
context: AssistantEditorContext | null;
|
||||
}): string {
|
||||
const userPrompt = input.userPrompt.trim();
|
||||
const context = input.context;
|
||||
|
||||
const lines = [
|
||||
`User request: ${userPrompt}`,
|
||||
`Current editor context type: ${context?.tabType ?? 'none'}`,
|
||||
];
|
||||
|
||||
if (context?.id) {
|
||||
lines.push(`Current editor context id: ${context.id}`);
|
||||
}
|
||||
|
||||
if (context?.title) {
|
||||
lines.push(`Current editor context title: ${context.title}`);
|
||||
}
|
||||
|
||||
lines.push('Use this context when analyzing data and proposing UI updates.');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
73
src/renderer/navigation/chatSession.ts
Normal file
73
src/renderer/navigation/chatSession.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface ChatService {
|
||||
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
|
||||
sendMessage: (
|
||||
conversationId: string,
|
||||
message: string,
|
||||
metadata?: SendMessageMetadata,
|
||||
) => Promise<{ success: boolean; message?: string; error?: string } | null | undefined>;
|
||||
}
|
||||
|
||||
export interface SendMessageMetadata {
|
||||
surface?: 'tab' | 'sidebar';
|
||||
}
|
||||
|
||||
export interface EnsureConversationIdInput {
|
||||
currentConversationId: string | null;
|
||||
createTitle: string;
|
||||
chatService: Pick<ChatService, 'createConversation'>;
|
||||
}
|
||||
|
||||
export interface SendConversationMessageInput {
|
||||
conversationId: string;
|
||||
message: string;
|
||||
metadata?: SendMessageMetadata;
|
||||
chatService: Pick<ChatService, 'sendMessage'>;
|
||||
}
|
||||
|
||||
export interface SendConversationMessageResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function ensureConversationId(input: EnsureConversationIdInput): Promise<string> {
|
||||
if (input.currentConversationId) {
|
||||
return input.currentConversationId;
|
||||
}
|
||||
|
||||
const conversation = await input.chatService.createConversation(input.createTitle);
|
||||
if (!conversation?.id) {
|
||||
throw new Error('No conversation id returned');
|
||||
}
|
||||
|
||||
return conversation.id;
|
||||
}
|
||||
|
||||
export async function sendConversationMessage(
|
||||
input: SendConversationMessageInput,
|
||||
): Promise<SendConversationMessageResult> {
|
||||
const result = input.metadata
|
||||
? await input.chatService.sendMessage(input.conversationId, input.message, input.metadata)
|
||||
: await input.chatService.sendMessage(input.conversationId, input.message);
|
||||
|
||||
if (result?.success === false) {
|
||||
return {
|
||||
success: false,
|
||||
message: '',
|
||||
error: result.error || 'Failed to send message',
|
||||
};
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
success: false,
|
||||
message: '',
|
||||
error: 'No response returned',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || '',
|
||||
};
|
||||
}
|
||||
24
src/renderer/navigation/chatSurfaceMode.ts
Normal file
24
src/renderer/navigation/chatSurfaceMode.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type ChatSurfaceModeId = 'tab' | 'sidebar';
|
||||
|
||||
export interface ChatSurfaceMode {
|
||||
showModelSelector: boolean;
|
||||
showWelcomeTips: boolean;
|
||||
showToolMarkers: boolean;
|
||||
}
|
||||
|
||||
const CHAT_SURFACE_MODE_REGISTRY: Record<ChatSurfaceModeId, ChatSurfaceMode> = {
|
||||
tab: {
|
||||
showModelSelector: true,
|
||||
showWelcomeTips: true,
|
||||
showToolMarkers: true,
|
||||
},
|
||||
sidebar: {
|
||||
showModelSelector: false,
|
||||
showWelcomeTips: false,
|
||||
showToolMarkers: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function getChatSurfaceMode(modeId: ChatSurfaceModeId): ChatSurfaceMode {
|
||||
return CHAT_SURFACE_MODE_REGISTRY[modeId];
|
||||
}
|
||||
56
src/renderer/navigation/useChatMessageSender.ts
Normal file
56
src/renderer/navigation/useChatMessageSender.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { sendConversationMessage, type ChatService, type SendMessageMetadata } from './chatSession';
|
||||
|
||||
interface UseChatMessageSenderInput {
|
||||
chatService: Pick<ChatService, 'sendMessage'> | null | undefined;
|
||||
}
|
||||
|
||||
interface UseChatMessageSenderParams {
|
||||
conversationId: string;
|
||||
message: string;
|
||||
metadata?: SendMessageMetadata;
|
||||
}
|
||||
|
||||
export function useChatMessageSender(input: UseChatMessageSenderInput) {
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (params: UseChatMessageSenderParams) => {
|
||||
if (!input.chatService) {
|
||||
const error = 'Chat service unavailable';
|
||||
setLastError(error);
|
||||
return {
|
||||
success: false as const,
|
||||
message: '',
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await sendConversationMessage({
|
||||
conversationId: params.conversationId,
|
||||
message: params.message,
|
||||
metadata: params.metadata,
|
||||
chatService: input.chatService,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setLastError(result.error || 'Failed to send message');
|
||||
return result;
|
||||
}
|
||||
|
||||
setLastError(null);
|
||||
return result;
|
||||
},
|
||||
[input.chatService],
|
||||
);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setLastError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
lastError,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
127
src/renderer/navigation/useChatSurfaceState.ts
Normal file
127
src/renderer/navigation/useChatSurfaceState.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { ChatMessage } from '../types/electron';
|
||||
|
||||
export interface ChatToolEvent {
|
||||
type: 'call' | 'result';
|
||||
name: string;
|
||||
args?: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function useChatSurfaceState() {
|
||||
const [messages, setMessagesState] = useState<ChatMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [toolEvents, setToolEvents] = useState<ChatToolEvent[]>([]);
|
||||
const streamingRef = useRef('');
|
||||
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
|
||||
|
||||
const setMessages = useCallback((nextMessages: ChatMessage[]) => {
|
||||
setMessagesState(nextMessages);
|
||||
}, []);
|
||||
|
||||
const beginUserTurn = useCallback((conversationId: string, content: string) => {
|
||||
const userMessage: ChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessagesState((prev) => [...prev, userMessage]);
|
||||
setIsStreaming(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingContent('');
|
||||
setToolEvents([]);
|
||||
toolEventsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const appendStreamDelta = useCallback((delta: string) => {
|
||||
streamingRef.current += delta;
|
||||
setStreamingContent(streamingRef.current);
|
||||
}, []);
|
||||
|
||||
const recordToolCall = useCallback((name: string, args?: unknown) => {
|
||||
toolEventsRef.current.push({ name, args });
|
||||
setToolEvents((prev) => [...prev, { type: 'call', name, args, timestamp: Date.now() }]);
|
||||
}, []);
|
||||
|
||||
const recordToolResult = useCallback((name: string) => {
|
||||
setToolEvents((prev) => [...prev, { type: 'result', name, timestamp: Date.now() }]);
|
||||
}, []);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
}, []);
|
||||
|
||||
const appendAssistantMessage = useCallback((conversationId: string, content: string) => {
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessagesState((prev) => [...prev, assistantMessage]);
|
||||
}, []);
|
||||
|
||||
const finalizeAssistantTurn = useCallback((conversationId: string, content: string) => {
|
||||
if (content) {
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content,
|
||||
toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessagesState((prev) => [...prev, assistantMessage]);
|
||||
}
|
||||
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
}, []);
|
||||
|
||||
const abortStreaming = useCallback((conversationId: string, cancelledSuffix: string) => {
|
||||
const partialContent = streamingRef.current;
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
|
||||
if (!partialContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const partialMessage: ChatMessage = {
|
||||
id: `partial-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: `${partialContent}\n\n*(${cancelledSuffix})*`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessagesState((prev) => [...prev, partialMessage]);
|
||||
}, []);
|
||||
|
||||
const getStreamingContent = useCallback(() => streamingRef.current, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toolEvents,
|
||||
setMessages,
|
||||
beginUserTurn,
|
||||
appendStreamDelta,
|
||||
recordToolCall,
|
||||
recordToolResult,
|
||||
appendAssistantMessage,
|
||||
finalizeAssistantTurn,
|
||||
stopStreaming,
|
||||
abortStreaming,
|
||||
getStreamingContent,
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,7 @@ interface AppState {
|
||||
activeView: SidebarView;
|
||||
sidebarVisible: boolean;
|
||||
panelVisible: boolean;
|
||||
assistantSidebarVisible: boolean;
|
||||
panelActiveTab: PanelTab;
|
||||
panelOutputEntries: PanelOutputEntry[];
|
||||
selectedPostId: string | null;
|
||||
@@ -119,6 +120,7 @@ interface AppState {
|
||||
setActiveView: (view: SidebarView) => void;
|
||||
toggleSidebar: () => void;
|
||||
togglePanel: () => void;
|
||||
toggleAssistantSidebar: () => void;
|
||||
setPanelActiveTab: (tab: PanelTab) => void;
|
||||
appendPanelOutputEntry: (entry: PanelOutputEntry) => void;
|
||||
clearPanelOutputEntries: () => void;
|
||||
@@ -175,6 +177,7 @@ export const useAppStore = create<AppState>()(
|
||||
activeView: 'posts',
|
||||
sidebarVisible: true,
|
||||
panelVisible: false,
|
||||
assistantSidebarVisible: false,
|
||||
panelActiveTab: 'tasks',
|
||||
panelOutputEntries: [],
|
||||
selectedPostId: null,
|
||||
@@ -300,6 +303,7 @@ export const useAppStore = create<AppState>()(
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
||||
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
||||
toggleAssistantSidebar: () => set((state) => ({ assistantSidebarVisible: !state.assistantSidebarVisible })),
|
||||
setPanelActiveTab: (panelActiveTab) => set({ panelActiveTab }),
|
||||
appendPanelOutputEntry: (entry) => set((state) => ({
|
||||
panelOutputEntries: [...state.panelOutputEntries, entry],
|
||||
|
||||
27
src/renderer/styles/chatSurface.css
Normal file
27
src/renderer/styles/chatSurface.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.chat-surface {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-surface-scroll {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-surface-input {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.chat-surface-error {
|
||||
margin: 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-surface-section {
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
Reference in New Issue
Block a user