resources
This commit is contained in:
+2
-1
@@ -6,7 +6,7 @@ type TaskRunnerDialogProps = {
|
||||
};
|
||||
|
||||
export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps) => {
|
||||
const { runningTask, setRunningTask, refresh, homeRoot, currentPath } = fileBrowserManager;
|
||||
const { runningTask, setRunningTask, refresh, homeRoot, currentPath, getEntryAbsPath } = fileBrowserManager;
|
||||
|
||||
if (!runningTask) return null;
|
||||
|
||||
@@ -21,6 +21,7 @@ export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps)
|
||||
}}
|
||||
task={runningTask.task}
|
||||
entryName={runningTask.entry.name}
|
||||
entryFullPath={getEntryAbsPath(runningTask.entry.name)}
|
||||
entryType={runningTask.entry.type}
|
||||
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
|
||||
/>
|
||||
|
||||
+139
-22
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Play, Square, CircleCheck } from 'lucide-react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import type { TaskInfo } from '../../../Chat';
|
||||
import { usePiChat, EmbeddableChat } from '../../../Chat';
|
||||
import type { TaskInfo, ChatMessage } from '../../../Chat';
|
||||
import { usePiChat, MessageBubble, StreamingBubble, ModelSelector } from '../../../Chat';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useVisiblePiModels } from 'state/useModels';
|
||||
import type { TaskSummary } from '../../useTasks';
|
||||
|
||||
const playDing = () => {
|
||||
@@ -32,6 +33,8 @@ const playDing = () => {
|
||||
setTimeout(() => ctx.close(), 1500);
|
||||
};
|
||||
|
||||
type Phase = 'ready' | 'running' | 'done';
|
||||
|
||||
type PiMonoInnerProps = {
|
||||
defaultInput: string;
|
||||
cwd: { root?: string; path: string };
|
||||
@@ -39,27 +42,139 @@ type PiMonoInnerProps = {
|
||||
taskInfo: TaskInfo;
|
||||
};
|
||||
|
||||
const PiMonoInner = ({
|
||||
defaultInput,
|
||||
cwd,
|
||||
initialModel,
|
||||
taskInfo,
|
||||
}: PiMonoInnerProps) => {
|
||||
const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo }: PiMonoInnerProps) => {
|
||||
const [phase, setPhase] = useState<Phase>('ready');
|
||||
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||
const availableModels = useVisiblePiModels();
|
||||
|
||||
// --- Independent message accumulator (never loses messages) ---
|
||||
const accRef = useRef<ChatMessage[]>([]);
|
||||
const seenToolIdsRef = useRef(new Set<string>());
|
||||
const seenResultRef = useRef(false);
|
||||
const [, bump] = useState(0);
|
||||
|
||||
// Track tool/result messages from chat.messages (idempotent during render)
|
||||
for (const m of chat.messages) {
|
||||
if (m.role === 'tool' && 'toolCallId' in m) {
|
||||
if (!seenToolIdsRef.current.has(m.toolCallId)) {
|
||||
seenToolIdsRef.current.add(m.toolCallId);
|
||||
accRef.current.push(m);
|
||||
} else {
|
||||
// Update existing tool message (e.g. output arrived)
|
||||
const idx = accRef.current.findIndex(
|
||||
(a) => a.role === 'tool' && 'toolCallId' in a && a.toolCallId === m.toolCallId,
|
||||
);
|
||||
if (idx >= 0) accRef.current[idx] = m;
|
||||
}
|
||||
}
|
||||
if (m.role === 'result' && !seenResultRef.current) {
|
||||
seenResultRef.current = true;
|
||||
accRef.current.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture assistant text when streaming is committed (streamingText goes non-empty → empty)
|
||||
const lastStreamRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (lastStreamRef.current && !chat.streamingText) {
|
||||
const text = lastStreamRef.current;
|
||||
const isDuplicate = accRef.current.some((a) => a.role === 'assistant' && 'text' in a && a.text === text);
|
||||
if (!isDuplicate) {
|
||||
accRef.current.push({ role: 'assistant', text });
|
||||
bump((n) => n + 1);
|
||||
}
|
||||
lastStreamRef.current = '';
|
||||
}
|
||||
if (chat.streamingText) {
|
||||
lastStreamRef.current = chat.streamingText;
|
||||
}
|
||||
}, [chat.streamingText]);
|
||||
|
||||
// Auto-scroll
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chat.messages, chat.streamingText]);
|
||||
|
||||
// Ding on completion + transition to done
|
||||
const wasGenerating = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasGenerating.current && !chat.isGenerating) playDing();
|
||||
if (wasGenerating.current && !chat.isGenerating) {
|
||||
playDing();
|
||||
setPhase('done');
|
||||
}
|
||||
wasGenerating.current = chat.isGenerating;
|
||||
}, [chat.isGenerating]);
|
||||
|
||||
const handleRun = () => {
|
||||
setPhase('running');
|
||||
chat.sendPrompt(defaultInput, undefined, undefined, cwd);
|
||||
};
|
||||
|
||||
if (phase === 'ready') {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={!chat.isConnected}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-3 border-t border-duck-dark/10">
|
||||
<ModelSelector
|
||||
messages={[]}
|
||||
availableModels={availableModels}
|
||||
selectedModel={chat.selectedModel}
|
||||
onModelChange={chat.setSelectedModel}
|
||||
model={chat.model}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={false}
|
||||
hasStarted={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Show StreamingBubble with fallback: use lastStreamRef while the effect hasn't captured yet
|
||||
const showStream = chat.streamingText || lastStreamRef.current;
|
||||
|
||||
return (
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
defaultInput={defaultInput}
|
||||
cwd={cwd}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{accRef.current.map((msg, i) => (
|
||||
<div key={i} className="px-4 py-1.5">
|
||||
<MessageBubble message={msg} onAnswer={() => {}} />
|
||||
</div>
|
||||
))}
|
||||
{showStream && (
|
||||
<div className="px-4 py-1.5">
|
||||
<StreamingBubble text={showStream} />
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<div className="shrink-0 flex justify-center py-3 border-t border-duck-dark/10">
|
||||
{phase === 'running' ? (
|
||||
<button
|
||||
onClick={chat.stopGeneration}
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
|
||||
<CircleCheck className="h-4 w-4" />
|
||||
Task complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,18 +183,20 @@ type TaskRunnerModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
task: TaskSummary;
|
||||
entryName?: string;
|
||||
entryFullPath?: string;
|
||||
entryType?: 'file' | 'directory';
|
||||
cwd?: { root?: string; path: string };
|
||||
promptOverride?: string;
|
||||
};
|
||||
|
||||
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
|
||||
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
|
||||
const { settings } = useSettings();
|
||||
const taskSettings = settings.tasks;
|
||||
const entryRef = entryFullPath ?? entryName;
|
||||
const defaultInput = promptOverride
|
||||
?? (entryName && entryType
|
||||
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`
|
||||
: `Read the task instructions at ${task.filePath} and execute them`);
|
||||
?? (entryRef && entryType
|
||||
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryRef}\n\nBe verbose — explain each step you take and what the result was.`
|
||||
: `Read the task instructions at ${task.filePath} and execute them\n\nBe verbose — explain each step you take and what the result was.`);
|
||||
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
|
||||
|
||||
return (
|
||||
@@ -102,7 +219,7 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
{/* Chat */}
|
||||
{/* Task Runner */}
|
||||
<PiMonoInner
|
||||
key="pi"
|
||||
defaultInput={defaultInput}
|
||||
|
||||
@@ -712,6 +712,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleBackgroundClick,
|
||||
getEntryAbsPath: (name: string) => absPath(entryPath(name)),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TextRenderer } from './TextRenderer';
|
||||
import { highlight } from './highlight';
|
||||
|
||||
type CodeRendererProps = {
|
||||
content: string;
|
||||
@@ -11,14 +12,9 @@ export const CodeRenderer = ({ content, lang }: CodeRendererProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
import('shiki')
|
||||
.then(({ codeToHtml }) => codeToHtml(content, { lang, theme: 'github-dark-default' }))
|
||||
.then((result) => {
|
||||
if (!cancelled) setHtml(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHtml(null);
|
||||
});
|
||||
highlight(content, lang).then((result) => {
|
||||
if (!cancelled) setHtml(result);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -3,12 +3,27 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import { highlight } from './highlight';
|
||||
|
||||
type HastNode = {
|
||||
type: string;
|
||||
value?: string;
|
||||
tagName?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
children?: HastNode[];
|
||||
};
|
||||
|
||||
type MarkdownRendererProps = {
|
||||
content: string;
|
||||
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
function getNodeText(node: HastNode): string {
|
||||
if (node.type === 'text') return node.value ?? '';
|
||||
if (node.children) return node.children.map(getNodeText).join('');
|
||||
return '';
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = ({ content, scrollContainer }: MarkdownRendererProps) => {
|
||||
const handleAnchorClick = (ev: React.MouseEvent<HTMLElement>) => {
|
||||
const target = (ev.target as HTMLElement).closest('a');
|
||||
@@ -35,23 +50,22 @@ export const MarkdownRenderer = ({ content, scrollContainer }: MarkdownRendererP
|
||||
</a>
|
||||
);
|
||||
},
|
||||
pre({ children }) {
|
||||
return <div className="relative">{children}</div>;
|
||||
},
|
||||
code({ className, children, ...props }) {
|
||||
const isBlock = className?.startsWith('language-');
|
||||
const lang = className?.replace('language-', '') ?? '';
|
||||
const text = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!isBlock) {
|
||||
return (
|
||||
<code className="px-1.5 py-0.5 rounded bg-duck-teal/10 text-duck-teal text-[0.85em] font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
pre({ node, children }) {
|
||||
const codeChild = node?.children[0];
|
||||
if (codeChild?.type === 'element' && codeChild.tagName === 'code') {
|
||||
const classes = (codeChild.properties?.className ?? []) as string[];
|
||||
const lang = classes[0]?.replace('language-', '') ?? '';
|
||||
const text = getNodeText(codeChild).replace(/\n$/, '');
|
||||
return <HighlightedCodeBlock code={text} lang={lang} />;
|
||||
}
|
||||
|
||||
return <HighlightedCodeBlock code={text} lang={lang} />;
|
||||
return <pre>{children}</pre>;
|
||||
},
|
||||
code({ children, ...props }) {
|
||||
return (
|
||||
<code className="px-1.5 py-0.5 rounded bg-duck-teal/10 text-duck-teal text-[0.85em] font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -84,39 +98,32 @@ const HighlightedCodeBlock = ({ code, lang }: { code: string; lang: string }) =>
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
import('shiki')
|
||||
.then(({ codeToHtml }) => codeToHtml(code, { lang, theme: 'github-dark-default' }))
|
||||
.then((result) => {
|
||||
if (!cancelled) setHtml(result);
|
||||
})
|
||||
.catch(() => {});
|
||||
highlight(code, lang).then((result) => {
|
||||
if (!cancelled) setHtml(result);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, lang]);
|
||||
|
||||
if (html) {
|
||||
return (
|
||||
<div className="relative my-4">
|
||||
<CopyButton text={code} />
|
||||
{lang && (
|
||||
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider z-10">{lang}</span>
|
||||
)}
|
||||
return (
|
||||
<div className="relative my-4">
|
||||
<CopyButton text={code} />
|
||||
{lang && (
|
||||
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider z-10">
|
||||
{lang}
|
||||
</span>
|
||||
)}
|
||||
{html ? (
|
||||
<div
|
||||
className="[&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:pt-8 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:font-mono [&_pre]:leading-relaxed [&_pre]:border [&_pre]:border-white/5 [&_code]:font-mono"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="relative rounded-lg bg-[#0d1117] text-[#e6edf3] p-4 overflow-x-auto text-sm font-mono leading-relaxed my-4 border border-white/5">
|
||||
<CopyButton text={code} />
|
||||
{lang && (
|
||||
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider">{lang}</span>
|
||||
) : (
|
||||
<pre className="rounded-lg bg-[#0d1117] text-[#e6edf3] p-4 pt-8 overflow-x-auto text-sm font-mono leading-relaxed border border-white/5">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
<code className="block pt-4">{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki';
|
||||
import { createHighlighter } from 'shiki';
|
||||
|
||||
const THEME = 'github-dark-default' as const;
|
||||
|
||||
let instance: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
|
||||
|
||||
function getHighlighter() {
|
||||
if (!instance) {
|
||||
instance = createHighlighter({ themes: [THEME], langs: [] });
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
export async function highlight(code: string, lang: string): Promise<string | null> {
|
||||
try {
|
||||
const highlighter = await getHighlighter();
|
||||
if (lang && !highlighter.getLoadedLanguages().includes(lang)) {
|
||||
await highlighter.loadLanguage(lang as BundledLanguage);
|
||||
}
|
||||
return highlighter.codeToHtml(code, { lang: lang || 'text', theme: THEME });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ export { useRecentModels } from './useRecentModels';
|
||||
export { usePlans } from './usePlans';
|
||||
export { useLandingPage } from './useLandingPage';
|
||||
export { useServerSettings } from './useServerSettings';
|
||||
export { useResources, getResourceCategory } from './useResources';
|
||||
export type { Resource, ResourceCredentials, ResourceConnectionConfig, PingResult, ResourceCategory } from './useResources';
|
||||
export { useResources } from './useResources';
|
||||
export type { ResourceSummary, ResourceDetail, PingResult } from './useResources';
|
||||
export { useChatSessions } from './useChatSessions';
|
||||
export type { UseChatSessionsType } from './useChatSessions';
|
||||
export { useChatGroups } from './useChatGroups';
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export type ResourceCredentials = {
|
||||
apiKey?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type ResourceConnectionConfig = {
|
||||
url: string;
|
||||
credentials?: ResourceCredentials;
|
||||
};
|
||||
|
||||
export type Resource = {
|
||||
id: string;
|
||||
export type ResourceSummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
type: string;
|
||||
port: string | null;
|
||||
description: string;
|
||||
installCommand: string | null;
|
||||
uninstallCommand: string | null;
|
||||
manageCommand: string | null;
|
||||
verifyCommand: string | null;
|
||||
updateCommand: string | null;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
connectionConfig: ResourceConnectionConfig | null;
|
||||
scope: 'native' | 'global';
|
||||
config: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ResourceDetail = ResourceSummary & {
|
||||
body: string;
|
||||
rawFrontmatter: string;
|
||||
filePath: string;
|
||||
configPath: string;
|
||||
chatSessionId: string | null;
|
||||
guidePath: string;
|
||||
};
|
||||
|
||||
export type PingResult = {
|
||||
@@ -34,10 +23,6 @@ export type PingResult = {
|
||||
latencyMs: number | null;
|
||||
};
|
||||
|
||||
export type ResourceCategory = 'api-based' | 'local-cli';
|
||||
|
||||
export const getResourceCategory = (r: Resource): ResourceCategory => (r.port ? 'api-based' : 'local-cli');
|
||||
|
||||
const RESOURCES_KEY = ['RESOURCES'];
|
||||
|
||||
export const useResources = () => {
|
||||
@@ -46,23 +31,31 @@ export const useResources = () => {
|
||||
|
||||
const { data: resources, isLoading } = useQuery({
|
||||
queryKey: RESOURCES_KEY,
|
||||
queryFn: () => client.get<Resource[]>('/server-settings/resources'),
|
||||
queryFn: () => client.get<ResourceSummary[]>('/server-settings/resources'),
|
||||
});
|
||||
|
||||
const saveConnectionConfig = async (id: string, config: Partial<ResourceConnectionConfig>) => {
|
||||
await client.patch(`/server-settings/resources/config/${id}`, config);
|
||||
const getDetail = (name: string) =>
|
||||
client.get<ResourceDetail>(`/server-settings/resources/${name}`);
|
||||
|
||||
const saveConfig = async (name: string, config: Record<string, string | null>) => {
|
||||
await client.patch(`/server-settings/resources/${name}/config`, config);
|
||||
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
|
||||
};
|
||||
|
||||
const pingResource = async (id: string, url?: string) => {
|
||||
return client.post<PingResult>(`/server-settings/resources/${id}/ping`, { url });
|
||||
};
|
||||
|
||||
const runCommand = async (id: string, action: string) => {
|
||||
const result = await client.post<{ exitCode: number; output: string }>(`/server-settings/resources/${id}/run`, { action });
|
||||
const createResource = async (name: string) => {
|
||||
const result = await client.post<{ name: string; dirName: string }>('/server-settings/resources', { name });
|
||||
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
|
||||
return result;
|
||||
};
|
||||
|
||||
return { resources, isLoading, saveConnectionConfig, pingResource, runCommand };
|
||||
const deleteResource = async (name: string) => {
|
||||
await client.delete(`/server-settings/resources/${name}`);
|
||||
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
|
||||
};
|
||||
|
||||
const pingResource = async (name: string, url?: string) => {
|
||||
return client.post<PingResult>(`/server-settings/resources/${name}/ping`, { url });
|
||||
};
|
||||
|
||||
return { resources, isLoading, getDetail, saveConfig, createResource, deleteResource, pingResource };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user