improved chat ui
This commit is contained in:
+26
-1
@@ -5,7 +5,8 @@ import { EmbedAuthWrapper } from './components/EmbedAuthWrapper';
|
||||
import { CriticalView } from './views/CriticalView';
|
||||
import { AuthCallback } from './components/Auth';
|
||||
import { pb } from './lib/pocketbase';
|
||||
import { initStore, setStore } from './store';
|
||||
import { initStore, setStore, store, appendAIHelpTipHistory } from './store';
|
||||
import { generateAIHelpTip, hasFireworksApiKey } from './lib/ai-help';
|
||||
|
||||
// const CriticalView = lazy(() => import('./views/CriticalView').then(m => ({ default: m.CriticalView })));
|
||||
const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ default: m.UrgencyView })));
|
||||
@@ -27,6 +28,8 @@ const App: Component = () => {
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
|
||||
const [location, setLocation] = createSignal(window.location.href);
|
||||
const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches);
|
||||
const [aiHelpTip, setAiHelpTip] = createSignal<string | null>(null);
|
||||
const [hasRequestedSessionTip, setHasRequestedSessionTip] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
const view = currentView();
|
||||
@@ -39,6 +42,25 @@ const App: Component = () => {
|
||||
currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView()
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const ready = isAuthenticated() && isDesktop() && !store.isInitializing;
|
||||
if (!ready) return;
|
||||
if (!hasFireworksApiKey()) return;
|
||||
if (hasRequestedSessionTip()) return;
|
||||
|
||||
setHasRequestedSessionTip(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const nextTip = await generateAIHelpTip(store.aiHelpTipHistory);
|
||||
if (!nextTip) return;
|
||||
setAiHelpTip(nextTip);
|
||||
await appendAIHelpTipHistory(nextTip);
|
||||
} catch (err) {
|
||||
console.error("Failed to generate AI help tip", err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 768px)");
|
||||
const openHelpGuide = (sectionId?: string) => {
|
||||
@@ -81,6 +103,8 @@ const App: Component = () => {
|
||||
const removeListener = pb.authStore.onChange((token) => {
|
||||
const wasAuthenticated = isAuthenticated();
|
||||
setIsAuthenticated(!!token);
|
||||
setHasRequestedSessionTip(false);
|
||||
setAiHelpTip(null);
|
||||
|
||||
if (token && (isFirstRun || !wasAuthenticated)) {
|
||||
initStore();
|
||||
@@ -155,6 +179,7 @@ const App: Component = () => {
|
||||
setView={setCurrentView}
|
||||
isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
|
||||
onToggleAIHelp={() => setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
|
||||
aiHelpTip={aiHelpTip()}
|
||||
hideQuickEntry={currentView() === "help_ai" && !isDesktop()}
|
||||
>
|
||||
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import { Bot, LoaderCircle, RefreshCw, Send } from "lucide-solid";
|
||||
import { MessageCircle, LoaderCircle, RefreshCw, Send } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
||||
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
|
||||
import { FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
||||
import { askAIHelp, hasFireworksApiKey } from "@/lib/ai-help";
|
||||
import { MarkdownMessage } from "./MarkdownMessage";
|
||||
|
||||
type ChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
const SYSTEM_PROMPT = `You are the TasGrid AI Help assistant.
|
||||
|
||||
You answer questions about TasGrid using the reference guide below.
|
||||
|
||||
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
|
||||
|
||||
If the guide does not contain the answer, say that the guide does not mention it.
|
||||
Do not use outside product knowledge.
|
||||
Do not invent buttons, workflows, settings, or behaviors.
|
||||
Prefer concise, practical answers.
|
||||
When helpful, mention the relevant section name from the guide.
|
||||
|
||||
TASGRID REFERENCE GUIDE
|
||||
${helpGuide}`;
|
||||
|
||||
const SUGGESTED_PROMPTS = [
|
||||
"How does sharing work in TasGrid?",
|
||||
"What is the difference between Handoff and Collaborative?",
|
||||
@@ -33,7 +19,7 @@ const SUGGESTED_PROMPTS = [
|
||||
"How do recurrence and templates behave?"
|
||||
];
|
||||
|
||||
const INITIAL_MESSAGE = "Ask about TasGrid features, workflows, or edge cases. I only answer from the reference guide, so if the guide does not mention something, I will tell you.";
|
||||
const INITIAL_MESSAGE = "Ask a question about how TasGrid works.";
|
||||
|
||||
export const AIHelpPanel: Component<{
|
||||
variant?: "page" | "sidebar";
|
||||
@@ -48,7 +34,10 @@ export const AIHelpPanel: Component<{
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
let messagesViewport: HTMLDivElement | undefined;
|
||||
|
||||
const hasApiKey = createMemo(() => FIREWORKS_API_KEY.trim().length > 0);
|
||||
const hasApiKey = createMemo(() => hasFireworksApiKey());
|
||||
const showSuggestions = createMemo(() =>
|
||||
hasApiKey() && messages().length === 1 && messages()[0]?.role === "assistant"
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
messages();
|
||||
@@ -79,7 +68,7 @@ export const AIHelpPanel: Component<{
|
||||
...nextMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "AI Help is not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable Fireworks responses."
|
||||
content: "Questions are not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable responses."
|
||||
}
|
||||
]);
|
||||
setIsLoading(false);
|
||||
@@ -87,39 +76,7 @@ export const AIHelpPanel: Component<{
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(FIREWORKS_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${FIREWORKS_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: FIREWORKS_HELP_MODEL,
|
||||
reasoning_effort: "low",
|
||||
temperature: 0.2,
|
||||
max_tokens: 700,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
...nextMessages.map(message => ({
|
||||
role: message.role,
|
||||
content: message.content
|
||||
}))
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text();
|
||||
throw new Error(bodyText || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const assistantText = data?.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!assistantText) {
|
||||
throw new Error("The help assistant did not return any text.");
|
||||
}
|
||||
|
||||
const assistantText = await askAIHelp(nextMessages);
|
||||
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
@@ -159,8 +116,8 @@ export const AIHelpPanel: Component<{
|
||||
isSidebar() ? "px-3 py-3" : "px-4 py-4 sm:px-5"
|
||||
)}>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Bot size={16} class="text-primary shrink-0" />
|
||||
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>AI Help</h1>
|
||||
<MessageCircle size={16} class="text-primary shrink-0" />
|
||||
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>Ask Questions</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -177,32 +134,12 @@ export const AIHelpPanel: Component<{
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5"
|
||||
)}>
|
||||
<Show
|
||||
when={hasApiKey()}
|
||||
fallback={
|
||||
<Show when={!hasApiKey()}>
|
||||
<div class={cn(
|
||||
"rounded-2xl border border-amber-500/30 bg-amber-500/10",
|
||||
isSidebar() ? "p-3 text-[0.75rem]" : "p-4 text-sm"
|
||||
)}>
|
||||
Add `VITE_FIREWORKS_API_KEY` to enable AI Help. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={SUGGESTED_PROMPTS}>
|
||||
{(prompt) => (
|
||||
<button
|
||||
class={cn(
|
||||
"rounded-full border border-border bg-background text-left font-medium text-foreground transition-colors hover:bg-muted",
|
||||
isSidebar() ? "px-2.5 py-1.5 text-[0.6875rem]" : "px-3 py-2 text-xs sm:text-sm"
|
||||
)}
|
||||
onClick={() => void submitQuestion(prompt)}
|
||||
disabled={isLoading()}
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
Add `VITE_FIREWORKS_API_KEY` to enable Ask Questions. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -215,11 +152,12 @@ export const AIHelpPanel: Component<{
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<div class="space-y-2">
|
||||
<div class={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
class={cn(
|
||||
"rounded-2xl px-4 py-3 leading-relaxed whitespace-pre-wrap shadow-sm",
|
||||
isSidebar() ? "max-w-[92%] text-[0.75rem]" : "max-w-[90%] sm:max-w-[80%] text-sm",
|
||||
isSidebar() ? "max-w-[92%]" : "max-w-[90%] sm:max-w-[80%]",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-card border border-border text-foreground"
|
||||
@@ -227,13 +165,39 @@ export const AIHelpPanel: Component<{
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
|
||||
<Show when={message.role === "assistant"} fallback={<span>You</span>}>
|
||||
<Bot size={12} />
|
||||
<span>Guide Assistant</span>
|
||||
<MessageCircle size={12} />
|
||||
<span>TasGrid Help</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div>{message.content}</div>
|
||||
<MarkdownMessage
|
||||
content={message.content}
|
||||
class={cn(
|
||||
message.role === "user"
|
||||
? "prose-invert prose-headings:text-primary-foreground prose-p:text-primary-foreground prose-strong:text-primary-foreground prose-code:bg-primary-foreground/15 prose-code:text-primary-foreground prose-a:text-primary-foreground prose-blockquote:text-primary-foreground"
|
||||
: ""
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={showSuggestions() && message.role === "assistant"}>
|
||||
<div class={cn("flex flex-wrap gap-2", isSidebar() ? "pl-1" : "pl-1")}>
|
||||
<For each={SUGGESTED_PROMPTS}>
|
||||
{(prompt) => (
|
||||
<button
|
||||
class={cn(
|
||||
"rounded-full border border-border bg-card text-left font-medium text-foreground transition-colors hover:bg-muted",
|
||||
isSidebar() ? "px-2.5 py-1.5 text-[0.6875rem]" : "px-3 py-2 text-xs sm:text-sm"
|
||||
)}
|
||||
onClick={() => void submitQuestion(prompt)}
|
||||
disabled={isLoading()}
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -241,15 +205,15 @@ export const AIHelpPanel: Component<{
|
||||
<div class="flex justify-start">
|
||||
<div class={cn(
|
||||
"rounded-2xl px-4 py-3 leading-relaxed shadow-sm bg-card border border-border text-foreground",
|
||||
isSidebar() ? "max-w-[92%] text-[0.75rem]" : "max-w-[80%] text-sm"
|
||||
isSidebar() ? "max-w-[92%] text-sm" : "max-w-[80%] text-sm"
|
||||
)}>
|
||||
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
|
||||
<Bot size={12} />
|
||||
<span>Guide Assistant</span>
|
||||
<MessageCircle size={12} />
|
||||
<span>TasGrid Help</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
Looking through the guide...
|
||||
Finding an answer...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,7 +246,7 @@ export const AIHelpPanel: Component<{
|
||||
placeholder="Ask how a TasGrid feature works, what a setting does, or why something may behave a certain way..."
|
||||
class={cn(
|
||||
"flex w-full rounded-xl border border-input bg-background transition-colors placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none leading-relaxed",
|
||||
isSidebar() ? "min-h-[88px] px-3 py-2.5 text-[0.75rem]" : "min-h-[96px] px-3 py-2.5 text-sm"
|
||||
isSidebar() ? "min-h-[88px] px-3 py-2.5 text-sm" : "min-h-[96px] px-3 py-2.5 text-sm"
|
||||
)}
|
||||
/>
|
||||
<div class={cn(
|
||||
@@ -295,7 +259,7 @@ export const AIHelpPanel: Component<{
|
||||
disabled={isLoading() || !input().trim()}
|
||||
>
|
||||
<Send size={14} />
|
||||
Ask AI Help
|
||||
Ask Question
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -17,6 +17,7 @@ interface LayoutProps {
|
||||
setView: (v: string) => void;
|
||||
isAIHelpOpen?: boolean;
|
||||
onToggleAIHelp?: () => void;
|
||||
aiHelpTip?: string | null;
|
||||
hideQuickEntry?: boolean;
|
||||
}
|
||||
|
||||
@@ -116,6 +117,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
setView={props.setView}
|
||||
isAIHelpOpen={!!props.isAIHelpOpen}
|
||||
onToggleAIHelp={() => props.onToggleAIHelp?.()}
|
||||
aiHelpTip={props.aiHelpTip}
|
||||
isLocked={isSidebarLocked()}
|
||||
setIsLocked={setIsSidebarLocked}
|
||||
isPeeking={isSidebarPeeking()}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { type Component, createMemo } from "solid-js";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const applyInlineMarkdown = (value: string) => {
|
||||
let text = escapeHtml(value);
|
||||
|
||||
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
text = text.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?:;]|$)/g, '$1<em>$2</em>');
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const html: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
const codeLines: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i < lines.length) i += 1;
|
||||
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<ul>${items.join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<ol>${items.join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
quoteLines.push(applyInlineMarkdown(lines[i].trim().replace(/^>\s?/, "")));
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<blockquote><p>${quoteLines.join("<br />")}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() && !lines[i].trim().startsWith("```") && !/^[-*]\s+/.test(lines[i].trim()) && !/^\d+\.\s+/.test(lines[i].trim()) && !/^>\s?/.test(lines[i].trim())) {
|
||||
paragraphLines.push(applyInlineMarkdown(lines[i].trim()));
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<p>${paragraphLines.join("<br />")}</p>`);
|
||||
}
|
||||
|
||||
return html.join("");
|
||||
};
|
||||
|
||||
export const MarkdownMessage: Component<{
|
||||
content: string;
|
||||
class?: string;
|
||||
}> = (props) => {
|
||||
const html = createMemo(() => renderMarkdown(props.content));
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"prose prose-sm dark:prose-invert max-w-none",
|
||||
"prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-0",
|
||||
"prose-pre:my-3 prose-pre:rounded-lg prose-pre:bg-muted prose-pre:p-3",
|
||||
"prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.9em]",
|
||||
"prose-code:before:content-none prose-code:after:content-none",
|
||||
"prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
|
||||
"prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic",
|
||||
props.class
|
||||
)}
|
||||
innerHTML={html()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Component, For, Show } from "solid-js";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle, Bot } from "lucide-solid";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle, MessageCircle } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -42,7 +42,6 @@ const mobileNavGroups: MobileNavGroup[] = [
|
||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||
]
|
||||
},
|
||||
{ icon: Bot, label: "AI Help", view: "help_ai" },
|
||||
{
|
||||
icon: Gauge, label: "Flow", items: [
|
||||
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
||||
@@ -50,6 +49,7 @@ const mobileNavGroups: MobileNavGroup[] = [
|
||||
{ icon: BarChart3, label: "Progress", view: "progress" },
|
||||
]
|
||||
},
|
||||
{ icon: MessageCircle, label: "Ask", view: "help_ai" },
|
||||
{ icon: Settings, label: "Settings", view: "settings" },
|
||||
];
|
||||
|
||||
@@ -306,6 +306,7 @@ export const Sidebar: Component<{
|
||||
setView: (v: string) => void;
|
||||
isAIHelpOpen: boolean;
|
||||
onToggleAIHelp: () => void;
|
||||
aiHelpTip?: string | null;
|
||||
isLocked: boolean;
|
||||
setIsLocked: (v: boolean) => void;
|
||||
isPeeking: boolean;
|
||||
@@ -356,7 +357,17 @@ export const Sidebar: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="px-3 pb-2">
|
||||
<ContextSwitcher
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 px-3 space-y-1 mt-2 min-h-0">
|
||||
<For each={desktopNavItems}>
|
||||
@@ -387,34 +398,28 @@ export const Sidebar: Component<{
|
||||
<AIHelpPanel variant="sidebar" class="h-[32rem]" />
|
||||
</div>
|
||||
|
||||
{/* Context Switcher - Only visible if there are oversight contexts */}
|
||||
<div class={cn(
|
||||
"transition-all duration-200",
|
||||
(!props.isLocked && !props.isPeeking) ? "opacity-0 pointer-events-none" : "opacity-100 delay-100"
|
||||
)}>
|
||||
<ContextSwitcher
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border pt-3 space-y-1">
|
||||
<button
|
||||
onClick={props.onToggleAIHelp}
|
||||
class={cn(
|
||||
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
|
||||
"flex w-full items-start gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm text-left",
|
||||
props.isAIHelpOpen
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Bot size={16} class={cn("transition-transform group-hover:scale-110")} />
|
||||
<span class="font-medium">AI Help</span>
|
||||
<MessageCircle size={16} class={cn("transition-transform group-hover:scale-110 mt-0.5 shrink-0")} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium">Ask Questions</div>
|
||||
<div
|
||||
class={cn(
|
||||
"text-[0.625rem] leading-snug transition-opacity duration-700",
|
||||
props.aiHelpTip ? "opacity-50" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
{props.aiHelpTip || " "}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.setView("help")}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
||||
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
|
||||
|
||||
type ChatRequestMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
const HELP_SYSTEM_PROMPT = `You answer questions about TasGrid using the reference guide below.
|
||||
|
||||
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
|
||||
|
||||
If the guide does not contain the answer, say that the guide does not mention it.
|
||||
Do not use outside product knowledge.
|
||||
Do not invent buttons, workflows, settings, or behaviors.
|
||||
Prefer concise, practical answers.
|
||||
When helpful, mention the relevant section name from the guide.
|
||||
|
||||
TASGRID REFERENCE GUIDE
|
||||
${helpGuide}`;
|
||||
|
||||
const normalizeLine = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.replace(/^["'\s]+|["'\s]+$/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
|
||||
export const hasFireworksApiKey = () => FIREWORKS_API_KEY.trim().length > 0;
|
||||
|
||||
const requestFireworks = async (messages: ChatRequestMessage[], options?: { maxTokens?: number; temperature?: number }) => {
|
||||
const response = await fetch(FIREWORKS_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${FIREWORKS_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: FIREWORKS_HELP_MODEL,
|
||||
reasoning_effort: "low",
|
||||
temperature: options?.temperature ?? 0.2,
|
||||
max_tokens: options?.maxTokens ?? 700,
|
||||
messages
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text();
|
||||
throw new Error(bodyText || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data?.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!content) {
|
||||
throw new Error("The help assistant did not return any text.");
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export const askAIHelp = async (messages: Array<{ role: "user" | "assistant"; content: string }>) =>
|
||||
requestFireworks(
|
||||
[
|
||||
{ role: "system", content: HELP_SYSTEM_PROMPT },
|
||||
...messages
|
||||
],
|
||||
{ maxTokens: 700, temperature: 0.2 }
|
||||
);
|
||||
|
||||
export const generateAIHelpTip = async (history: string[]) => {
|
||||
const recentHistory = history.slice(-10);
|
||||
const historyBlock = recentHistory.length > 0
|
||||
? recentHistory.map((tip, index) => `${index + 1}. ${tip}`).join("\n")
|
||||
: "No previous tips.";
|
||||
|
||||
const tipPrompt = `Create one short TasGrid desktop tip based only on the guide.
|
||||
|
||||
Rules:
|
||||
- Maximum one sentence.
|
||||
- Return plain text only.
|
||||
- No title, no bullet, no quotes.
|
||||
- Keep it subtle and practical.
|
||||
- Do not repeat or closely paraphrase any previous tip.
|
||||
- Use only this guide for all information and never assume you know something that isn't mentioned in the guide.
|
||||
|
||||
Previous tips to avoid:
|
||||
${historyBlock}`;
|
||||
|
||||
const seen = new Set(recentHistory.map(normalizeLine));
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const response = await requestFireworks(
|
||||
[
|
||||
{ role: "system", content: HELP_SYSTEM_PROMPT },
|
||||
{ role: "user", content: tipPrompt }
|
||||
],
|
||||
{ maxTokens: 80, temperature: 0.5 }
|
||||
);
|
||||
|
||||
const firstLine = response.split("\n")[0]?.trim() || "";
|
||||
const cleaned = firstLine.replace(/^[-*•\d.\s]+/, "").trim();
|
||||
const normalized = normalizeLine(cleaned);
|
||||
|
||||
if (!cleaned || !normalized) continue;
|
||||
if (seen.has(normalized)) continue;
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -28,6 +28,7 @@ export const syncPreferences = async () => {
|
||||
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
|
||||
subscribedBuckets: store.subscribedBuckets,
|
||||
supervisorUserIds: store.personalContextSupervisorIds,
|
||||
aiHelpTipHistory: store.aiHelpTipHistory,
|
||||
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
|
||||
};
|
||||
|
||||
@@ -174,3 +175,15 @@ export const removeNoteFilterTemplate = async (id: string) => {
|
||||
export const clearNoteFilter = () => {
|
||||
setStore("noteFilter", createDefaultFilter());
|
||||
};
|
||||
|
||||
export const appendAIHelpTipHistory = async (tip: string) => {
|
||||
const normalizedTip = tip.trim();
|
||||
if (!normalizedTip) return;
|
||||
|
||||
setStore("aiHelpTipHistory", prev => {
|
||||
const next = [...prev.filter(existing => existing !== normalizedTip), normalizedTip];
|
||||
return next.slice(-10);
|
||||
});
|
||||
|
||||
await syncPreferences();
|
||||
};
|
||||
|
||||
@@ -183,6 +183,7 @@ export interface TaskStore {
|
||||
buckets: Bucket[];
|
||||
subscribedBuckets: string[];
|
||||
personalContextSupervisorIds: string[];
|
||||
aiHelpTipHistory: string[];
|
||||
notes: Note[];
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
@@ -202,6 +203,7 @@ export interface ScopedTaskgridPrefs {
|
||||
noteFilter?: Filter;
|
||||
subscribedBuckets?: string[];
|
||||
supervisorUserIds?: string[];
|
||||
aiHelpTipHistory?: string[];
|
||||
dismissedTaskUpdateIndicators?: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||
migratedStructuredTagsV2?: boolean;
|
||||
@@ -246,6 +248,7 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
buckets: [],
|
||||
subscribedBuckets: [],
|
||||
personalContextSupervisorIds: [],
|
||||
aiHelpTipHistory: [],
|
||||
notes: [],
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: [],
|
||||
@@ -295,6 +298,7 @@ export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => {
|
||||
prefId: currentUserId,
|
||||
subscribedBuckets: prefs.subscribedBuckets || [],
|
||||
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
|
||||
aiHelpTipHistory: prefs.aiHelpTipHistory || [],
|
||||
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
||||
filterTemplates: prefs.filterTemplates || [],
|
||||
quickloadTasks: prefs.quickloadTasks || [],
|
||||
|
||||
Reference in New Issue
Block a user