Merge remote-tracking branch 'origin/int'
This commit is contained in:
commit
e727996a18
20 changed files with 767 additions and 156 deletions
|
|
@ -36,6 +36,7 @@ export interface PaginationParams {
|
|||
search?: string;
|
||||
viewKey?: string;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||
owner?: 'all' | 'me' | 'shared';
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
|
|
@ -109,6 +110,7 @@ export async function fetchFiles(
|
|||
if (params.search) paginationObj.search = params.search;
|
||||
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||
if (params.owner) requestParams.owner = params.owner;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||
const [versionLoading, setVersionLoading] = useState(false);
|
||||
const didBootstrapEmptyCanvasRef = useRef(false);
|
||||
|
||||
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
||||
|
||||
|
|
@ -598,8 +599,22 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
|
||||
useEffect(() => {
|
||||
if (loading || nodeTypes.length === 0) return;
|
||||
if (currentWorkflowId || initialWorkflowId) return;
|
||||
if (canvasNodes.length > 0) return;
|
||||
if (currentWorkflowId || initialWorkflowId) {
|
||||
didBootstrapEmptyCanvasRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (didBootstrapEmptyCanvasRef.current) return;
|
||||
didBootstrapEmptyCanvasRef.current = true;
|
||||
if (canvasNodes.length === 0 && canvasConnections.length === 0 && invocations.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.debug(`${LOG} bootstrapping empty canvas`, {
|
||||
currentWorkflowId,
|
||||
initialWorkflowId,
|
||||
canvasNodes: canvasNodes.length,
|
||||
canvasConnections: canvasConnections.length,
|
||||
invocations: invocations.length,
|
||||
});
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, [], {
|
||||
skipHistory: true,
|
||||
});
|
||||
|
|
@ -609,8 +624,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
currentWorkflowId,
|
||||
initialWorkflowId,
|
||||
canvasNodes.length,
|
||||
canvasConnections.length,
|
||||
invocations.length,
|
||||
applyGraphWithSync,
|
||||
t,
|
||||
]);
|
||||
|
||||
const toggleCategory = useCallback((id: string) => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
|||
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||
import { switchOutputLabel } from '../nodes/shared/graphUtils';
|
||||
|
||||
const LOG = '[FlowCanvas]';
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string;
|
||||
type: string;
|
||||
|
|
@ -842,6 +844,8 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
|
||||
const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
|
||||
onHistoryCheckpointRef.current = onHistoryCheckpoint;
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
|
||||
const emitHistoryCheckpoint = useCallback(() => {
|
||||
onHistoryCheckpointRef.current?.();
|
||||
|
|
@ -1019,12 +1023,19 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
]
|
||||
);
|
||||
|
||||
const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({
|
||||
nodeId: null,
|
||||
signature: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelectionChange) {
|
||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
||||
onSelectionChange(node);
|
||||
}
|
||||
}, [selectedNodeId, nodes, onSelectionChange]);
|
||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
||||
const signature = node ? JSON.stringify(node) : null;
|
||||
const last = lastEmittedSelectionRef.current;
|
||||
if (last.nodeId === selectedNodeId && last.signature === signature) return;
|
||||
lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature };
|
||||
onSelectionChangeRef.current?.(node);
|
||||
}, [selectedNodeId, nodes]);
|
||||
|
||||
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1088,6 +1099,11 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
console.debug(`${LOG} drop received`, {
|
||||
types: Array.from(e.dataTransfer.types),
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
|
||||
if (onExternalDrop) {
|
||||
const reservedMimes = new Set([
|
||||
|
|
@ -1113,16 +1129,35 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
}
|
||||
// 2) Standard: Node-Type aus der NodeSidebar
|
||||
const raw = e.dataTransfer.getData('application/json');
|
||||
if (!raw || !containerRef.current) return;
|
||||
if (!raw || !containerRef.current) {
|
||||
console.debug(`${LOG} drop ignored`, {
|
||||
hasRaw: Boolean(raw),
|
||||
hasContainer: Boolean(containerRef.current),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { type } = JSON.parse(raw);
|
||||
const el = containerRef.current;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
|
||||
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
|
||||
console.debug(`${LOG} placing node from drop`, {
|
||||
type,
|
||||
raw,
|
||||
dropX: x,
|
||||
dropY: y,
|
||||
panOffset,
|
||||
zoom,
|
||||
});
|
||||
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
||||
emitHistoryCheckpoint();
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
console.debug(`${LOG} drop parse failed`, {
|
||||
raw,
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } f
|
|||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
|
||||
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
||||
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
||||
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
||||
|
|
@ -253,6 +254,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
|
||||
for (const param of sortedParameters) {
|
||||
if (param.frontendType === 'hidden') continue;
|
||||
if (param.name === 'context') continue;
|
||||
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
|
||||
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
|
||||
|
||||
|
|
@ -378,6 +380,15 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
t,
|
||||
]);
|
||||
|
||||
const extractContentContextParam = useMemo((): NodeTypeParameter | null => {
|
||||
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
|
||||
const param = sortedParameters.find((p) => p.name === 'context') ?? null;
|
||||
if (!param) return null;
|
||||
if (param.frontendType === 'hidden') return null;
|
||||
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
|
||||
return param;
|
||||
}, [node, nodeType, sortedParameters, params]);
|
||||
|
||||
if (!node || !nodeType) return null;
|
||||
|
||||
const isTrigger = node.type.startsWith('trigger.');
|
||||
|
|
@ -483,11 +494,71 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
</div>
|
||||
)}
|
||||
{extractContentAccordionItems !== null ? (
|
||||
<AccordionList<string>
|
||||
key={`${node.id}-extract-accordion`}
|
||||
defaultOpenId={null}
|
||||
items={extractContentAccordionItems}
|
||||
/>
|
||||
<>
|
||||
{extractContentContextParam ? (
|
||||
<div
|
||||
key={`${node.id}-${extractContentContextParam.name}`}
|
||||
style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 2,
|
||||
flexWrap: 'wrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{extractContentContextParam.required && (
|
||||
<span
|
||||
title={t('Pflichtfeld')}
|
||||
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{verboseSchema && extractContentContextParam.type && (
|
||||
<span
|
||||
title={t('Parameter-Typ')}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 4,
|
||||
padding: '1px 6px',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{extractContentContextParam.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ContextBuilderRenderer
|
||||
param={extractContentContextParam}
|
||||
value={workflowParamUiValue(params, extractContentContextParam)}
|
||||
onChange={(val: unknown) => updateParam(extractContentContextParam.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
onPatchParams={patchParams}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{extractContentAccordionItems.length > 0 ? (
|
||||
<AccordionList<string>
|
||||
key={`${node.id}-extract-accordion`}
|
||||
defaultOpenId={null}
|
||||
items={extractContentAccordionItems}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
parameters.map((param: NodeTypeParameter) => {
|
||||
// Safety net: hidden params have no UI footprint at all — no row,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 3px 30px 3px 6px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #334155);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 25px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
color: var(--color-text-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
background: none;
|
||||
color: var(--color-text-secondary, #94a3b8);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import React, { type Ref } from 'react';
|
||||
import styles from './FilterSearchInput.module.css';
|
||||
|
||||
export interface FilterSearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
onInputClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
/** When set, only `inputClassName` styles the input (for floating-label toolbar search). */
|
||||
variant?: 'compact' | 'inherit';
|
||||
inputClassName?: string;
|
||||
wrapperClassName?: string;
|
||||
clearTitle?: string;
|
||||
}
|
||||
|
||||
export function FilterSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Filter...',
|
||||
inputRef,
|
||||
onInputClick,
|
||||
onFocus,
|
||||
onBlur,
|
||||
variant = 'compact',
|
||||
inputClassName,
|
||||
wrapperClassName,
|
||||
clearTitle = 'Eingabe löschen',
|
||||
}: FilterSearchInputProps) {
|
||||
const inputClass = variant === 'inherit'
|
||||
? inputClassName
|
||||
: inputClassName
|
||||
? `${styles.input} ${inputClassName}`
|
||||
: styles.input;
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName ? `${styles.wrapper} ${wrapperClassName}` : styles.wrapper}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
onClick={onInputClick}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange('');
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={clearTitle}
|
||||
tabIndex={-1}
|
||||
aria-label={clearTitle}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/components/FormGenerator/FilterSearchInput/index.ts
Normal file
2
src/components/FormGenerator/FilterSearchInput/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { FilterSearchInput } from './FilterSearchInput';
|
||||
export type { FilterSearchInputProps } from './FilterSearchInput';
|
||||
|
|
@ -168,7 +168,7 @@
|
|||
.searchInput {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 28px 8px 12px;
|
||||
border: 1px solid var(--color-border, #E2E8F0);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import type { IconType } from 'react-icons';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './FormGeneratorControls.module.css';
|
||||
import { FilterSearchInput } from '../FilterSearchInput';
|
||||
import { Button } from '../../UiComponents/Button';
|
||||
import { IoIosRefresh } from "react-icons/io";
|
||||
import { FaTrash, FaDownload } from "react-icons/fa";
|
||||
|
|
@ -189,14 +190,15 @@ export function FormGeneratorControls({
|
|||
<div className={styles.searchContainer}>
|
||||
{searchable && (
|
||||
<div className={styles.floatingLabelInput}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder=" "
|
||||
<FilterSearchInput
|
||||
variant="inherit"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onChange={onSearchChange}
|
||||
placeholder=" "
|
||||
onFocus={() => onSearchFocus(true)}
|
||||
onBlur={() => onSearchFocus(false)}
|
||||
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
||||
inputClassName={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
||||
clearTitle={t('Suche löschen')}
|
||||
/>
|
||||
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
|
||||
{t('Suchen...')}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import {
|
|||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
|
||||
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||
import { FilterSearchInput } from '../FilterSearchInput';
|
||||
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
||||
import {
|
||||
isDateTimeType,
|
||||
|
|
@ -446,22 +447,11 @@ function FilterValuesList({
|
|||
<>
|
||||
{showSearch && (
|
||||
<div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
<FilterSearchInput
|
||||
inputRef={searchInputRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
||||
placeholder="Filter..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '3px 6px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid var(--border-color, #ccc)',
|
||||
borderRadius: '3px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(value) => { setSearchTerm(value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
||||
onInputClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { FaFolder, FaFile, FaTrash } from 'react-icons/fa';
|
||||
import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types';
|
||||
import api from '../../../../api';
|
||||
import { getUserDataCache } from '../../../../utils/userCache';
|
||||
|
||||
interface FolderData {
|
||||
id: string;
|
||||
|
|
@ -137,7 +136,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
if ((f.parentId ?? null) === null) out.add(f.id);
|
||||
}
|
||||
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 });
|
||||
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam } });
|
||||
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam, owner } });
|
||||
const data = filesRes.data;
|
||||
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
|
||||
? (Array.isArray(data.items) ? data.items : [])
|
||||
|
|
@ -193,7 +192,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
}
|
||||
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
|
||||
const filesRes = await api.get('/api/files/list', {
|
||||
params: { pagination: paginationParam },
|
||||
params: { pagination: paginationParam, owner },
|
||||
});
|
||||
const data = filesRes.data;
|
||||
let rawFiles: FileData[] = [];
|
||||
|
|
@ -203,10 +202,6 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
rawFiles = data;
|
||||
}
|
||||
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
|
||||
if (ownership === 'shared') {
|
||||
const myId = getUserDataCache()?.id;
|
||||
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
|
||||
}
|
||||
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
|
||||
if (apiParentId === null) {
|
||||
for (const n of fileNodes) n.parentId = synthRootId;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ interface ChatsTabProps {
|
|||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||
onDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||
activeWorkflowId?: string;
|
||||
onCreateNew?: () => void;
|
||||
chatListRefreshKey?: number;
|
||||
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
|
||||
onDeleteChat?: (chatId: string) => void | Promise<void>;
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
onSelectChat,
|
||||
onDragStart,
|
||||
activeWorkflowId,
|
||||
onCreateNew,
|
||||
chatListRefreshKey,
|
||||
onRenameChat,
|
||||
onDeleteChat,
|
||||
}) => {
|
||||
|
|
@ -82,13 +82,14 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<ChatFilter>('active');
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const groupsRef = useRef(groups);
|
||||
groupsRef.current = groups;
|
||||
|
||||
const _loadChats = useCallback(async (serverSearch?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = { includeArchived: true };
|
||||
if (serverSearch) params.search = serverSearch;
|
||||
|
|
@ -140,7 +141,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
} catch (err) {
|
||||
console.error('Failed to load chats:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
}, [context.instanceId, t]);
|
||||
|
||||
|
|
@ -163,6 +164,12 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
}
|
||||
}, [activeWorkflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatListRefreshKey) {
|
||||
_loadChats();
|
||||
}
|
||||
}, [chatListRefreshKey, _loadChats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId && renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
|
|
@ -188,8 +195,18 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
const trimmed = editName.trim();
|
||||
setEditingId(null);
|
||||
if (!trimmed || !onRenameChat) return;
|
||||
await onRenameChat(chatId, trimmed);
|
||||
_loadChats();
|
||||
const prev = groupsRef.current;
|
||||
setGroups(gs => gs.map(g => ({
|
||||
...g,
|
||||
chats: g.chats.map(c => (c.id === chatId ? { ...c, label: trimmed } : c)),
|
||||
})));
|
||||
try {
|
||||
await onRenameChat(chatId, trimmed);
|
||||
_loadChats();
|
||||
} catch (err) {
|
||||
console.error('Failed to rename chat:', err);
|
||||
setGroups(prev);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
|
||||
|
|
@ -201,23 +218,41 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
}
|
||||
};
|
||||
|
||||
const _setChatStatus = useCallback((chatId: string, status: string) => {
|
||||
setGroups(gs => gs.map(g => ({
|
||||
...g,
|
||||
chats: g.chats.map(c => (c.id === chatId ? { ...c, status } : c)),
|
||||
})));
|
||||
}, []);
|
||||
|
||||
const _removeChat = useCallback((chatId: string) => {
|
||||
setGroups(gs => gs.map(g => ({
|
||||
...g,
|
||||
chats: g.chats.filter(c => c.id !== chatId),
|
||||
})).filter(g => g.chats.length > 0));
|
||||
}, []);
|
||||
|
||||
const _archiveChat = useCallback(async (chatId: string) => {
|
||||
const prev = groupsRef.current;
|
||||
_setChatStatus(chatId, 'archived');
|
||||
try {
|
||||
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
|
||||
_loadChats();
|
||||
} catch (err) {
|
||||
console.error('Failed to archive chat:', err);
|
||||
setGroups(prev);
|
||||
}
|
||||
}, [context.instanceId, _loadChats]);
|
||||
}, [context.instanceId, _setChatStatus]);
|
||||
|
||||
const _restoreChat = useCallback(async (chatId: string) => {
|
||||
const prev = groupsRef.current;
|
||||
_setChatStatus(chatId, 'active');
|
||||
try {
|
||||
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
|
||||
_loadChats();
|
||||
} catch (err) {
|
||||
console.error('Failed to restore chat:', err);
|
||||
setGroups(prev);
|
||||
}
|
||||
}, [context.instanceId, _loadChats]);
|
||||
}, [context.instanceId, _setChatStatus]);
|
||||
|
||||
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
|
||||
|
||||
|
|
@ -311,7 +346,17 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
{onDeleteChat && (
|
||||
<button
|
||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const prev = groupsRef.current;
|
||||
_removeChat(chat.id);
|
||||
try {
|
||||
await onDeleteChat(chat.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete chat:', err);
|
||||
setGroups(prev);
|
||||
}
|
||||
}}
|
||||
title={t('Löschen')}
|
||||
>
|
||||
🗑️
|
||||
|
|
@ -334,8 +379,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
return labels[code] || code;
|
||||
};
|
||||
|
||||
if (loading) return <div className={styles.loading}>{t('Chats werden geladen…')}</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.chatsTab}>
|
||||
<div className={styles.toolbar}>
|
||||
|
|
@ -346,11 +389,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{onCreateNew && (
|
||||
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title={t('Neuer Chat')}>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
|
||||
onClick={() => setFlatMode(!flatMode)}
|
||||
|
|
@ -437,7 +475,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
</div>
|
||||
)}
|
||||
|
||||
{_allChats.length === 0 && (
|
||||
{hasLoadedOnce && _allChats.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,60 @@
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.uploadCircleButton {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #f25843;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.uploadCircleButton:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.uploadCircleWrap {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.uploadCircleSvg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.uploadCircleTrack {
|
||||
fill: none;
|
||||
stroke: rgba(242, 88, 67, 0.25);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.uploadCircleProgress {
|
||||
fill: none;
|
||||
stroke: #f25843;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 120ms linear;
|
||||
}
|
||||
|
||||
.uploadCircleText {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #f25843;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fileRow:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
const { showSuccess, showError } = useToast();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
||||
const uploadRunIdRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const provider = useMemo(() => createFolderFileProvider(), []);
|
||||
|
|
@ -54,21 +56,41 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
|
||||
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||
if (!context.instanceId || uploading) return;
|
||||
uploadRunIdRef.current += 1;
|
||||
const runId = uploadRunIdRef.current;
|
||||
setUploading(true);
|
||||
setUploadProgressPercent(0);
|
||||
try {
|
||||
for (const file of Array.from(fileList)) {
|
||||
const files = Array.from(fileList);
|
||||
const totalFiles = files.length || 1;
|
||||
for (const [index, file] of files.entries()) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('featureInstanceId', context.instanceId);
|
||||
await api.post('/api/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: progressEvent => {
|
||||
if (uploadRunIdRef.current !== runId) return;
|
||||
if (!progressEvent.total) return;
|
||||
const fileProgress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
|
||||
const baseProgress = (index / totalFiles) * 100;
|
||||
const scaledFileProgress = fileProgress / totalFiles;
|
||||
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
|
||||
},
|
||||
});
|
||||
}
|
||||
if (uploadRunIdRef.current === runId) setUploadProgressPercent(100);
|
||||
_handleRefresh();
|
||||
} catch (err) {
|
||||
console.error('File upload failed:', err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (uploadRunIdRef.current === runId) {
|
||||
setUploading(false);
|
||||
// Let 100% render briefly, then reset.
|
||||
window.setTimeout(() => {
|
||||
if (uploadRunIdRef.current === runId) setUploadProgressPercent(0);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
}, [context.instanceId, uploading, _handleRefresh]);
|
||||
|
||||
|
|
@ -135,6 +157,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
|
||||
}, [onSendToChat]);
|
||||
|
||||
const circleRadius = 11;
|
||||
const circleCircumference = 2 * Math.PI * circleRadius;
|
||||
const circleOffset = circleCircumference * (1 - uploadProgressPercent / 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.filesTab}
|
||||
|
|
@ -170,10 +196,26 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
||||
className={styles.uploadCircleButton}
|
||||
title={t('Dateien hochladen')}
|
||||
>
|
||||
{uploading ? '...' : '+'}
|
||||
{uploading ? (
|
||||
<span className={styles.uploadCircleWrap} aria-hidden="true">
|
||||
<svg className={styles.uploadCircleSvg} viewBox="0 0 24 24">
|
||||
<circle className={styles.uploadCircleTrack} cx="12" cy="12" r={circleRadius} />
|
||||
<circle
|
||||
className={styles.uploadCircleProgress}
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={circleRadius}
|
||||
style={{ strokeDasharray: `${circleCircumference}`, strokeDashoffset: `${circleOffset}` }}
|
||||
/>
|
||||
</svg>
|
||||
<span className={styles.uploadCircleText}>{uploadProgressPercent}%</span>
|
||||
</span>
|
||||
) : (
|
||||
'+'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={_handleRefresh}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ interface UnifiedDataBarProps {
|
|||
hideTabs?: UdbTab[];
|
||||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||
activeWorkflowId?: string;
|
||||
onCreateNewChat?: () => void;
|
||||
onRenameChat?: (chatId: string, newName: string) => void;
|
||||
chatListRefreshKey?: number;
|
||||
onDeleteChat?: (chatId: string) => void;
|
||||
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||
|
|
@ -78,8 +78,8 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
hideTabs,
|
||||
onSelectChat,
|
||||
activeWorkflowId,
|
||||
onCreateNewChat,
|
||||
onRenameChat,
|
||||
chatListRefreshKey,
|
||||
onDeleteChat,
|
||||
onChatDragStart,
|
||||
onFileSelect,
|
||||
|
|
@ -122,7 +122,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
onSelectChat={onSelectChat}
|
||||
onDragStart={onChatDragStart}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
onCreateNew={onCreateNewChat}
|
||||
chatListRefreshKey={chatListRefreshKey}
|
||||
onRenameChat={onRenameChat}
|
||||
onDeleteChat={onDeleteChat}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export interface PaginationParams {
|
|||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
owner?: 'all' | 'me' | 'shared';
|
||||
}
|
||||
|
||||
// Files list hook
|
||||
|
|
@ -150,6 +151,7 @@ export function useUserFiles() {
|
|||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
||||
owner?: 'all' | 'me' | 'shared';
|
||||
}) => {
|
||||
const levels = base.groupByLevels?.length
|
||||
? base.groupByLevels
|
||||
|
|
@ -164,7 +166,11 @@ export function useUserFiles() {
|
|||
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||
const { data } = await api.get('/api/files/list', {
|
||||
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||
params: {
|
||||
mode: 'groupSummary',
|
||||
pagination: JSON.stringify(pObj),
|
||||
...(base.owner ? { owner: base.owner } : {}),
|
||||
},
|
||||
});
|
||||
return Array.isArray(data?.groups) ? data.groups : [];
|
||||
},
|
||||
|
|
@ -192,7 +198,10 @@ export function useUserFiles() {
|
|||
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||
const { data } = await api.get('/api/files/list', {
|
||||
params: { pagination: JSON.stringify(pObj) },
|
||||
params: {
|
||||
pagination: JSON.stringify(pObj),
|
||||
...(paginationParams.owner ? { owner: paginationParams.owner } : {}),
|
||||
},
|
||||
});
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
return { items: data.items, pagination: data.pagination };
|
||||
|
|
@ -408,6 +417,7 @@ export function useFileOperations() {
|
|||
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
||||
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isLoading] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
|
@ -564,9 +574,11 @@ export function useFileOperations() {
|
|||
file: globalThis.File,
|
||||
workflowId?: string,
|
||||
featureInstanceId?: string,
|
||||
onProgress?: (progress: number) => void,
|
||||
) => {
|
||||
setUploadError(null);
|
||||
setUploadingFile(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
|
||||
|
|
@ -593,7 +605,14 @@ export function useFileOperations() {
|
|||
|
||||
|
||||
// Do NOT set Content-Type manually – axios sets multipart/form-data with boundary for FormData
|
||||
const response = await api.post('/api/files/upload', formData);
|
||||
const response = await api.post('/api/files/upload', formData, {
|
||||
onUploadProgress: progressEvent => {
|
||||
if (!progressEvent.total) return;
|
||||
const progress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
|
||||
setUploadProgress(progress);
|
||||
onProgress?.(progress);
|
||||
},
|
||||
});
|
||||
const fileData = response.data;
|
||||
|
||||
// Check if the response indicates a duplicate file
|
||||
|
|
@ -625,6 +644,7 @@ export function useFileOperations() {
|
|||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setUploadingFile(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -749,6 +769,7 @@ export function useFileOperations() {
|
|||
deletingFiles,
|
||||
editingFiles,
|
||||
uploadingFile,
|
||||
uploadProgress,
|
||||
downloadError,
|
||||
deleteError,
|
||||
uploadError,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,17 @@ interface UserFile {
|
|||
}
|
||||
|
||||
type ViewMode = 'folder' | 'all';
|
||||
type FileOwnerScope = 'all' | 'me' | 'shared';
|
||||
|
||||
function normalizeFolderFilterId(folderId: string | null): string | null {
|
||||
if (!folderId) return null;
|
||||
if (folderId.startsWith('__filesRoot:')) return null;
|
||||
return folderId;
|
||||
}
|
||||
|
||||
function isSyntheticRootFolderId(folderId: string | null): boolean {
|
||||
return Boolean(folderId && folderId.startsWith('__filesRoot:'));
|
||||
}
|
||||
|
||||
export const FilesPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -67,14 +78,16 @@ export const FilesPage: React.FC = () => {
|
|||
handleInlineUpdate,
|
||||
deletingFiles,
|
||||
downloadingFiles,
|
||||
uploadingFile,
|
||||
previewingFiles,
|
||||
} = useFileOperations();
|
||||
|
||||
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own');
|
||||
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
||||
const [isUploadingBatch, setIsUploadingBatch] = useState(false);
|
||||
|
||||
const [treeWidth, setTreeWidth] = useState(300);
|
||||
const [treeVisible, setTreeVisible] = useState(true);
|
||||
|
|
@ -103,14 +116,24 @@ export const FilesPage: React.FC = () => {
|
|||
const _tableRefetch = useCallback(async (params?: any) => {
|
||||
const nextParams = { ...(params || {}) };
|
||||
const nextFilters = { ...(nextParams.filters || {}) };
|
||||
if (viewMode === 'folder' && selectedFolderId) {
|
||||
nextFilters.folderId = selectedFolderId;
|
||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
||||
const owner: FileOwnerScope =
|
||||
selectedOwnership === 'own'
|
||||
? 'me'
|
||||
: selectedOwnership === 'shared'
|
||||
? 'shared'
|
||||
: 'all';
|
||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
||||
nextFilters.folderId = normalizedFolderId;
|
||||
} else {
|
||||
delete nextFilters.folderId;
|
||||
}
|
||||
nextParams.filters = nextFilters;
|
||||
if (owner !== 'all') nextParams.owner = owner;
|
||||
else delete nextParams.owner;
|
||||
await tableRefetch(nextParams);
|
||||
}, [tableRefetch, selectedFolderId, viewMode]);
|
||||
}, [tableRefetch, selectedFolderId, selectedOwnership, viewMode]);
|
||||
|
||||
const fetchGroupSectionSummaries = useCallback(
|
||||
async (base: {
|
||||
|
|
@ -122,12 +145,20 @@ export const FilesPage: React.FC = () => {
|
|||
groupDirection?: 'asc' | 'desc';
|
||||
}) => {
|
||||
const filters = { ...(base.filters || {}) };
|
||||
if (viewMode === 'folder' && selectedFolderId) {
|
||||
filters.folderId = selectedFolderId;
|
||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
||||
filters.folderId = normalizedFolderId;
|
||||
}
|
||||
return fetchGroupSectionSummariesFromHook({ ...base, filters });
|
||||
const owner: FileOwnerScope =
|
||||
selectedOwnership === 'own'
|
||||
? 'me'
|
||||
: selectedOwnership === 'shared'
|
||||
? 'shared'
|
||||
: 'all';
|
||||
return fetchGroupSectionSummariesFromHook({ ...base, filters, owner });
|
||||
},
|
||||
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId],
|
||||
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId, selectedOwnership],
|
||||
);
|
||||
|
||||
const refetchForSection = useCallback(
|
||||
|
|
@ -137,12 +168,20 @@ export const FilesPage: React.FC = () => {
|
|||
parentColumnFilters?: Record<string, unknown>,
|
||||
) => {
|
||||
const merged = { ...(parentColumnFilters || {}) };
|
||||
if (viewMode === 'folder' && selectedFolderId) {
|
||||
merged.folderId = selectedFolderId;
|
||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
||||
merged.folderId = normalizedFolderId;
|
||||
}
|
||||
return refetchForSectionFromHook(paginationParams, sectionFilter, merged);
|
||||
const owner: FileOwnerScope =
|
||||
selectedOwnership === 'own'
|
||||
? 'me'
|
||||
: selectedOwnership === 'shared'
|
||||
? 'shared'
|
||||
: 'all';
|
||||
return refetchForSectionFromHook({ ...paginationParams, owner }, sectionFilter, merged);
|
||||
},
|
||||
[refetchForSectionFromHook, viewMode, selectedFolderId],
|
||||
[refetchForSectionFromHook, viewMode, selectedFolderId, selectedOwnership],
|
||||
);
|
||||
|
||||
const _refreshAll = useCallback(async () => {
|
||||
|
|
@ -152,14 +191,15 @@ export const FilesPage: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
_tableRefetch({ page: 1, pageSize: 25 });
|
||||
}, [selectedFolderId, viewMode, _tableRefetch]);
|
||||
}, [selectedFolderId, selectedOwnership, viewMode, _tableRefetch]);
|
||||
|
||||
// ── Tree interaction ──────────────────────────────────────────────────
|
||||
const _handleTreeNodeClick = useCallback((node: TreeNode) => {
|
||||
setSelectedOwnership(node.ownership);
|
||||
if (node.type === 'folder') {
|
||||
setSelectedFolderId(node.id);
|
||||
} else if (node.type === 'file') {
|
||||
setSelectedFolderId(node.parentId);
|
||||
setSelectedFolderId(node.parentId ?? null);
|
||||
setHighlightedFileId(node.id);
|
||||
requestAnimationFrame(() => {
|
||||
const row = document.querySelector('tr[data-highlighted="true"]');
|
||||
|
|
@ -264,24 +304,38 @@ export const FilesPage: React.FC = () => {
|
|||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files;
|
||||
if (picked && picked.length > 0) {
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
for (const file of Array.from(picked)) {
|
||||
const result = await handleFileUpload(file);
|
||||
if (result?.success) successCount++; else errorCount++;
|
||||
}
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await _tableRefetch();
|
||||
setTreeKey(k => k + 1);
|
||||
if (successCount > 0) {
|
||||
showSuccess(
|
||||
t('Upload erfolgreich'),
|
||||
errorCount > 0
|
||||
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
|
||||
: t('{successCount} Datei(en) hochgeladen', { successCount }),
|
||||
);
|
||||
} else if (errorCount > 0) {
|
||||
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
|
||||
setIsUploadingBatch(true);
|
||||
setUploadProgressPercent(0);
|
||||
try {
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const files = Array.from(picked);
|
||||
const totalFiles = files.length;
|
||||
for (const [index, file] of files.entries()) {
|
||||
const result = await handleFileUpload(file, undefined, undefined, fileProgress => {
|
||||
const baseProgress = (index / totalFiles) * 100;
|
||||
const scaledFileProgress = fileProgress / totalFiles;
|
||||
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
|
||||
});
|
||||
if (result?.success) successCount++; else errorCount++;
|
||||
}
|
||||
setUploadProgressPercent(100);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await _tableRefetch();
|
||||
setTreeKey(k => k + 1);
|
||||
if (successCount > 0) {
|
||||
showSuccess(
|
||||
t('Upload erfolgreich'),
|
||||
errorCount > 0
|
||||
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
|
||||
: t('{successCount} Datei(en) hochgeladen', { successCount }),
|
||||
);
|
||||
} else if (errorCount > 0) {
|
||||
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
|
||||
}
|
||||
} finally {
|
||||
setIsUploadingBatch(false);
|
||||
setUploadProgressPercent(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -436,8 +490,39 @@ export const FilesPage: React.FC = () => {
|
|||
<div style={{ flex: 1 }} />
|
||||
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
||||
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleUploadClick}
|
||||
disabled={isUploadingBatch}
|
||||
style={{ position: 'relative', overflow: 'hidden' }}
|
||||
>
|
||||
{isUploadingBatch && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${uploadProgressPercent}%`,
|
||||
background: 'rgba(255, 255, 255, 0.25)',
|
||||
transition: 'width 120ms linear',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<FaUpload />
|
||||
<span>{t('Datei hochladen')}</span>
|
||||
{isUploadingBatch && <span>{uploadProgressPercent}%</span>}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -851,6 +851,52 @@
|
|||
background: var(--surface-alt, #fafafa);
|
||||
}
|
||||
|
||||
.panelTitleBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--surface-alt, #fafafa);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panelTitleBar .panelTitle {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panelExpandBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-color, #fff);
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.panelExpandBtn:hover {
|
||||
background: var(--surface-alt, #f5f5f5);
|
||||
color: var(--primary-color, #4A90D9);
|
||||
border-color: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.popupPanelList {
|
||||
max-height: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.transcriptList,
|
||||
.responseList {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import styles from './Teamsbot.module.css';
|
|||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Popup } from '../../../components/UiComponents/Popup';
|
||||
|
||||
/**
|
||||
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
|
||||
|
|
@ -54,6 +55,8 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
const [screenshotsLoading, setScreenshotsLoading] = useState(false);
|
||||
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
|
||||
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
|
||||
const [transcriptPopupOpen, setTranscriptPopupOpen] = useState(false);
|
||||
const [botResponsesPopupOpen, setBotResponsesPopupOpen] = useState(false);
|
||||
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
|
||||
status: string;
|
||||
message?: string;
|
||||
|
|
@ -746,6 +749,64 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
const _renderExpandIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const _renderTranscriptList = (endRef?: React.RefObject<HTMLDivElement | null>) => (
|
||||
<>
|
||||
{transcripts.map((seg) => (
|
||||
<div key={seg.id} className={styles.transcriptItem}>
|
||||
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
|
||||
<span
|
||||
className={styles.transcriptSpeaker}
|
||||
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
|
||||
>
|
||||
{seg.speaker || t('Unbekannt')}:
|
||||
</span>
|
||||
<span className={styles.transcriptText}>{seg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
{endRef && <div ref={endRef} />}
|
||||
{transcripts.length === 0 && (
|
||||
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const _renderBotResponsesList = () => (
|
||||
<>
|
||||
{botResponses.map((r) => (
|
||||
<div key={r.id} className={styles.responseItem}>
|
||||
<div className={styles.responseHeader}>
|
||||
<span className={styles.responseIntent}>{r.detectedIntent}</span>
|
||||
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
|
||||
</div>
|
||||
<div className={styles.responseText}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
|
||||
</div>
|
||||
{r.reasoning && (
|
||||
<div className={styles.responseReasoning}>
|
||||
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
|
||||
</div>
|
||||
)}
|
||||
{(r.modelName || r.processingTime != null) && (
|
||||
<div className={styles.responseMeta}>
|
||||
<span>{r.modelName || ''}</span>
|
||||
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
|
||||
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{botResponses.length === 0 && (
|
||||
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
|
||||
if (noSessions) return (
|
||||
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
|
||||
|
|
@ -1154,63 +1215,69 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
<div className={styles.sessionContent}>
|
||||
{/* Left: Transcript */}
|
||||
<div className={styles.transcriptPanel}>
|
||||
<h4 className={styles.panelTitle}>
|
||||
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
||||
</h4>
|
||||
<div className={styles.panelTitleBar}>
|
||||
<h4 className={styles.panelTitle}>
|
||||
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.panelExpandBtn}
|
||||
onClick={() => setTranscriptPopupOpen(true)}
|
||||
title={t('Vollbild')}
|
||||
aria-label={t('Transkript im Vollbild anzeigen')}
|
||||
>
|
||||
{_renderExpandIcon()}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.transcriptList}>
|
||||
{transcripts.map((seg) => (
|
||||
<div key={seg.id} className={styles.transcriptItem}>
|
||||
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
|
||||
<span
|
||||
className={styles.transcriptSpeaker}
|
||||
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
|
||||
>
|
||||
{seg.speaker || t('Unbekannt')}:
|
||||
</span>
|
||||
<span className={styles.transcriptText}>{seg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={transcriptEndRef} />
|
||||
{transcripts.length === 0 && (
|
||||
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
|
||||
)}
|
||||
{_renderTranscriptList(transcriptEndRef)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Bot Responses */}
|
||||
<div className={styles.responsesPanel}>
|
||||
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
|
||||
<div className={styles.panelTitleBar}>
|
||||
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.panelExpandBtn}
|
||||
onClick={() => setBotResponsesPopupOpen(true)}
|
||||
title={t('Vollbild')}
|
||||
aria-label={t('Bot-Antworten im Vollbild anzeigen')}
|
||||
>
|
||||
{_renderExpandIcon()}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.responseList}>
|
||||
{botResponses.map((r) => (
|
||||
<div key={r.id} className={styles.responseItem}>
|
||||
<div className={styles.responseHeader}>
|
||||
<span className={styles.responseIntent}>{r.detectedIntent}</span>
|
||||
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
|
||||
</div>
|
||||
<div className={styles.responseText}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
|
||||
</div>
|
||||
{r.reasoning && (
|
||||
<div className={styles.responseReasoning}>
|
||||
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
|
||||
</div>
|
||||
)}
|
||||
{(r.modelName || r.processingTime != null) && (
|
||||
<div className={styles.responseMeta}>
|
||||
<span>{r.modelName || ''}</span>
|
||||
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
|
||||
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{botResponses.length === 0 && (
|
||||
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
|
||||
)}
|
||||
{_renderBotResponsesList()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Popup
|
||||
isOpen={transcriptPopupOpen}
|
||||
title={t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
||||
onClose={() => setTranscriptPopupOpen(false)}
|
||||
size="fullscreen"
|
||||
closeOnBackdropClick
|
||||
>
|
||||
<div className={`${styles.transcriptList} ${styles.popupPanelList}`}>
|
||||
{_renderTranscriptList()}
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
<Popup
|
||||
isOpen={botResponsesPopupOpen}
|
||||
title={`Bot-Antworten (${botResponses.length})`}
|
||||
onClose={() => setBotResponsesPopupOpen(false)}
|
||||
size="fullscreen"
|
||||
closeOnBackdropClick
|
||||
>
|
||||
<div className={`${styles.responseList} ${styles.popupPanelList}`}>
|
||||
{_renderBotResponsesList()}
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
{/* Summary (for ended sessions) */}
|
||||
{session.summary && (
|
||||
<div className={styles.summaryCard}>
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
);
|
||||
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
||||
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
||||
const [chatListRefreshKey, setChatListRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const _handleResize = () => {
|
||||
|
|
@ -254,6 +255,27 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
workspace.loadWorkflow(wfId);
|
||||
};
|
||||
|
||||
const sidebarHeaderBtnStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#888',
|
||||
};
|
||||
|
||||
const createChatBtnStyle: React.CSSProperties = {
|
||||
...sidebarHeaderBtnStyle,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
color: 'var(--text-secondary, #555)',
|
||||
};
|
||||
|
||||
const _handleCreateNewChat = useCallback(() => {
|
||||
workspace.resetToNew();
|
||||
setChatListRefreshKey(k => k + 1);
|
||||
}, [workspace]);
|
||||
|
||||
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
|
||||
flex: 1,
|
||||
padding: '6px 0',
|
||||
|
|
@ -356,7 +378,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
onTabChange={setUdbTab}
|
||||
onSelectChat={_handleConversationSelect}
|
||||
activeWorkflowId={workspace.workflowId ?? undefined}
|
||||
onCreateNewChat={workspace.resetToNew}
|
||||
chatListRefreshKey={chatListRefreshKey}
|
||||
onRenameChat={_handleRenameChat}
|
||||
onDeleteChat={_handleDeleteChat}
|
||||
onFileSelect={_handleFileSelect}
|
||||
|
|
@ -408,7 +430,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
}}>
|
||||
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
|
||||
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>◀</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
|
||||
<button onClick={() => setLeftCollapsed(true)} style={sidebarHeaderBtnStyle}>◀</button>
|
||||
</div>
|
||||
</div>
|
||||
{_leftPanelBody}
|
||||
</aside>
|
||||
|
|
@ -604,7 +629,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
>
|
||||
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
|
||||
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
|
||||
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
{_leftPanelBody}
|
||||
</aside>
|
||||
|
|
|
|||
Loading…
Reference in a new issue