fixed /chat
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import type { LayoutNode, SelectedSession } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
@@ -9,6 +9,25 @@ import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export { ChatHistory as ChatHistoryApp } from './Widget';
|
||||
|
||||
// Allowed panel app types for the /chat screen
|
||||
const ALLOWED_APP_TYPES = new Set<string | null>(['chat-session-list', 'chat-detail', null]);
|
||||
|
||||
/** Recursively fix any panel that uses a wrong app type (e.g. officerdev/chat) */
|
||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
if (!ALLOWED_APP_TYPES.has(node.appType)) {
|
||||
return { ...node, appType: 'chat-detail' };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
const children = node.children.map((c) => {
|
||||
const fixed = normalizeLayout(c.node);
|
||||
return fixed === c.node ? c : { ...c, node: fixed };
|
||||
});
|
||||
const changed = children.some((c, i) => c !== node.children[i]);
|
||||
return changed ? { ...node, children } : node;
|
||||
}
|
||||
|
||||
type SessionListPageProps = {
|
||||
isNew?: boolean;
|
||||
};
|
||||
@@ -17,7 +36,21 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const { sessions } = useChatSessions();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/chat', defaultLayout);
|
||||
const rawWorkspace = useWorkspacesState<LayoutNode>('screens/chat', defaultLayout);
|
||||
|
||||
// Normalize synchronously so the wrong panel never renders
|
||||
const workspace = useMemo(() => {
|
||||
const fixed = normalizeLayout(rawWorkspace.value);
|
||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||
return { ...rawWorkspace, value: fixed };
|
||||
}, [rawWorkspace]);
|
||||
|
||||
// Persist the fix to the backend
|
||||
useEffect(() => {
|
||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||
rawWorkspace.setValue(workspace.value);
|
||||
}
|
||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useVisiblePiModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
|
||||
|
||||
function buildGroups(models: ModelOption[]) {
|
||||
const groups: Record<string, { id: string; name: string }[]> = {};
|
||||
for (const m of models) {
|
||||
const provider = m.provider ?? 'Other';
|
||||
if (!groups[provider]) groups[provider] = [];
|
||||
groups[provider]!.push({ id: m.id, name: m.name });
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
||||
}
|
||||
|
||||
const NONE = '__none__';
|
||||
|
||||
export const AIModels = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const piModels = useVisiblePiModels();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [chatModel, setChatModel] = useState<string | null>(settings.chat.defaultModel);
|
||||
const [projectModel, setProjectModel] = useState<string | null>(settings.chat.defaultProjectModel);
|
||||
const [taskModel, setTaskModel] = useState<string | null>(settings.tasks.defaultModel);
|
||||
|
||||
useEffect(() => {
|
||||
setChatModel(settings.chat.defaultModel);
|
||||
setProjectModel(settings.chat.defaultProjectModel);
|
||||
setTaskModel(settings.tasks.defaultModel);
|
||||
}, [settings]);
|
||||
|
||||
const groups = useMemo(() => buildGroups(piModels), [piModels]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveSettings({
|
||||
...settings,
|
||||
chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel },
|
||||
tasks: { ...settings.tasks, defaultModel: taskModel },
|
||||
});
|
||||
toast.success('AI model defaults saved');
|
||||
} catch {
|
||||
toast.error('Failed to save settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderModelSelect = (value: string | null, onChange: (v: string | null) => void, placeholder: string) => (
|
||||
<Select value={value ?? NONE} onValueChange={(v) => onChange(v === NONE ? null : v)}>
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
<SelectItem value={NONE}>{placeholder}</SelectItem>
|
||||
{groups.map(({ provider, models }) => (
|
||||
<SelectGroup key={provider}>
|
||||
<SelectLabel>{getProviderDisplayName(provider)}</SelectLabel>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={`${provider}:${m.id}`} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat from the home screen</p>
|
||||
{renderModelSelect(chatModel, setChatModel, 'System default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project workspace</p>
|
||||
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when running tasks from the file browser</p>
|
||||
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { User, Lock, Globe, ListChecks } from 'lucide-react';
|
||||
import { User, Lock, Globe, Bot } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
|
||||
@@ -7,14 +7,14 @@ import { createSettingsPanelComponents, type SettingsSection } from '../Settings
|
||||
import { UserData } from './UserData';
|
||||
import { ChangePassword } from './ChangePassword';
|
||||
import { Languages } from './Languages';
|
||||
import { TaskDefaults } from './TaskDefaults';
|
||||
import { AIModels } from './AIModels';
|
||||
|
||||
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
|
||||
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: <ChangePassword /> },
|
||||
{ key: 'tasks', icon: ListChecks, title: 'Task Defaults', description: 'Default model for tasks', content: <TaskDefaults /> },
|
||||
{ key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: <AIModels /> },
|
||||
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
|
||||
];
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ function ChatDefaultsSection() {
|
||||
try {
|
||||
const updated: UserSettings = {
|
||||
...settings,
|
||||
chat: { defaultProvider: 'pi', defaultModel: model, systemPrompt, temperature, defaultPwd },
|
||||
chat: { ...settings.chat, defaultModel: model, systemPrompt, temperature, defaultPwd },
|
||||
};
|
||||
await saveSettings(updated);
|
||||
toast.success('Chat defaults saved');
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ChatPanelWrapper = () => {
|
||||
const sessionId = selection?.sessionId ?? undefined;
|
||||
const model = selection?.model ?? undefined;
|
||||
|
||||
const chat = usePiChat(sessionId, model, { replaceUrl: false });
|
||||
const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped });
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSession(chat.sessionId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { Trash2, Home, Monitor } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
@@ -66,6 +66,7 @@ type SessionChatProps = {
|
||||
};
|
||||
|
||||
function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||
const navigate = useNavigate();
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||
@@ -81,7 +82,7 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||
onDelete={async () => {
|
||||
await deleteSession(sessionId);
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
navigate('/chat', { replace: true });
|
||||
}}
|
||||
/>
|
||||
<EmbeddableChat
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
@@ -10,6 +11,7 @@ import { GroupContextMenu } from './GroupContextMenu';
|
||||
import { SessionContextMenu } from './SessionContextMenu';
|
||||
|
||||
export const SessionList = () => {
|
||||
const navigate = useNavigate();
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const { groups } = useChatGroups();
|
||||
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||
@@ -47,13 +49,13 @@ export const SessionList = () => {
|
||||
|
||||
const handleSelect = (session: (typeof sessions)[number]) => {
|
||||
setSelected({ id: session.id, model: session.model ?? null });
|
||||
window.history.replaceState(null, '', `/chat/${session.id}`);
|
||||
navigate(`/chat/${session.id}`, { replace: true });
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (selected?.id === id) {
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
navigate('/chat', { replace: true });
|
||||
}
|
||||
await deleteSession(id);
|
||||
};
|
||||
@@ -133,7 +135,7 @@ export const SessionList = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelected({ id: `new:${Date.now()}` });
|
||||
window.history.replaceState(null, '', '/chat/new');
|
||||
navigate('/chat/new', { replace: true });
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
>
|
||||
|
||||
@@ -16,10 +16,11 @@ type UsePiChatOptions = {
|
||||
storage?: ResourceChatStorage;
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
projectScoped?: boolean;
|
||||
};
|
||||
|
||||
export function usePiChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) {
|
||||
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
|
||||
const { replaceUrl = true, storage, resourceChatDir, taskInfo, projectScoped } = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
@@ -36,10 +37,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
||||
|
||||
// Set default model from settings when starting a new chat (no initialSessionId, no initialModel)
|
||||
useEffect(() => {
|
||||
if (!initialSessionId && !initialModel && settings?.chat?.defaultModel) {
|
||||
setSelectedModel(settings.chat.defaultModel);
|
||||
if (!initialSessionId && !initialModel) {
|
||||
const defaultModel = (projectScoped && settings?.chat?.defaultProjectModel) || settings?.chat?.defaultModel;
|
||||
if (defaultModel) setSelectedModel(defaultModel);
|
||||
}
|
||||
}, [initialSessionId, initialModel, settings]);
|
||||
}, [initialSessionId, initialModel, settings, projectScoped]);
|
||||
|
||||
// Sync selectedModel when initialModel changes (e.g. resuming a session)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,23 +31,15 @@ export function usePiModels() {
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const data = await client.get<{ models: ModelOption[]; providerNames?: Record<string, string>; hostHome?: string }>('/pi/models');
|
||||
|
||||
console.log('[usePiModels] Fetched models:', {
|
||||
modelCount: data.models.length,
|
||||
providerNames: data.providerNames,
|
||||
providers: [...new Set(data.models.map((m: ModelOption) => m.provider))]
|
||||
});
|
||||
|
||||
// Store provider names for later use
|
||||
|
||||
if (data.providerNames) {
|
||||
globalProviderNames = data.providerNames;
|
||||
console.log('[usePiModels] Stored provider names:', globalProviderNames);
|
||||
}
|
||||
|
||||
if (data.hostHome) {
|
||||
globalHostHome = data.hostHome;
|
||||
}
|
||||
|
||||
|
||||
return data.models;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
@@ -86,3 +78,23 @@ export function useVisiblePiModels() {
|
||||
return isExplicitlyEnabled || isFromNewProvider;
|
||||
});
|
||||
}
|
||||
|
||||
/** Strict filtering — only explicitly enabled models, no "new provider" passthrough. */
|
||||
export function useEnabledPiModels() {
|
||||
const models = usePiModels();
|
||||
const { settings } = useSettings();
|
||||
const enabled = settings.ai?.enabledModels ?? [];
|
||||
const disabledProviders = new Set(settings.ai?.disabledProviders ?? []);
|
||||
|
||||
const providerFiltered = disabledProviders.size > 0
|
||||
? models.filter((m) => !disabledProviders.has(m.provider))
|
||||
: models;
|
||||
|
||||
if (enabled.length === 0) return providerFiltered;
|
||||
|
||||
// Match by modelKey (new format), model id, or model name (legacy formats)
|
||||
const enabledSet = new Set(enabled);
|
||||
return providerFiltered.filter((m) =>
|
||||
enabledSet.has(modelKey(m)) || enabledSet.has(m.id) || enabledSet.has(m.name),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export type UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'pi';
|
||||
defaultModel: string | null;
|
||||
defaultProjectModel: string | null;
|
||||
systemPrompt: string;
|
||||
temperature: number;
|
||||
defaultPwd: string;
|
||||
@@ -79,6 +80,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'pi',
|
||||
defaultModel: null,
|
||||
defaultProjectModel: null,
|
||||
systemPrompt: '',
|
||||
temperature: 1,
|
||||
defaultPwd: '~',
|
||||
|
||||
Reference in New Issue
Block a user