fixed /chat

This commit is contained in:
2026-02-23 08:00:53 +00:00
parent 3e2c8090d5
commit d9b69c8209
10 changed files with 194 additions and 26 deletions
@@ -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(() => {
+22 -10
View File
@@ -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),
);
}
+2
View File
@@ -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: '~',