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 { CriticalView } from './views/CriticalView';
|
||||||
import { AuthCallback } from './components/Auth';
|
import { AuthCallback } from './components/Auth';
|
||||||
import { pb } from './lib/pocketbase';
|
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 CriticalView = lazy(() => import('./views/CriticalView').then(m => ({ default: m.CriticalView })));
|
||||||
const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ default: m.UrgencyView })));
|
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 [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
|
||||||
const [location, setLocation] = createSignal(window.location.href);
|
const [location, setLocation] = createSignal(window.location.href);
|
||||||
const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches);
|
const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches);
|
||||||
|
const [aiHelpTip, setAiHelpTip] = createSignal<string | null>(null);
|
||||||
|
const [hasRequestedSessionTip, setHasRequestedSessionTip] = createSignal(false);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const view = currentView();
|
const view = currentView();
|
||||||
@@ -39,6 +42,25 @@ const App: Component = () => {
|
|||||||
currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView()
|
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(() => {
|
onMount(() => {
|
||||||
const mediaQuery = window.matchMedia("(min-width: 768px)");
|
const mediaQuery = window.matchMedia("(min-width: 768px)");
|
||||||
const openHelpGuide = (sectionId?: string) => {
|
const openHelpGuide = (sectionId?: string) => {
|
||||||
@@ -81,6 +103,8 @@ const App: Component = () => {
|
|||||||
const removeListener = pb.authStore.onChange((token) => {
|
const removeListener = pb.authStore.onChange((token) => {
|
||||||
const wasAuthenticated = isAuthenticated();
|
const wasAuthenticated = isAuthenticated();
|
||||||
setIsAuthenticated(!!token);
|
setIsAuthenticated(!!token);
|
||||||
|
setHasRequestedSessionTip(false);
|
||||||
|
setAiHelpTip(null);
|
||||||
|
|
||||||
if (token && (isFirstRun || !wasAuthenticated)) {
|
if (token && (isFirstRun || !wasAuthenticated)) {
|
||||||
initStore();
|
initStore();
|
||||||
@@ -155,6 +179,7 @@ const App: Component = () => {
|
|||||||
setView={setCurrentView}
|
setView={setCurrentView}
|
||||||
isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
|
isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
|
||||||
onToggleAIHelp={() => setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
|
onToggleAIHelp={() => setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
|
||||||
|
aiHelpTip={aiHelpTip()}
|
||||||
hideQuickEntry={currentView() === "help_ai" && !isDesktop()}
|
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>}>
|
<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>}>
|
||||||
|
|||||||
+68
-104
@@ -1,30 +1,16 @@
|
|||||||
import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
import { FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
||||||
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
|
import { askAIHelp, hasFireworksApiKey } from "@/lib/ai-help";
|
||||||
|
import { MarkdownMessage } from "./MarkdownMessage";
|
||||||
|
|
||||||
type ChatMessage = {
|
type ChatMessage = {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
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 = [
|
const SUGGESTED_PROMPTS = [
|
||||||
"How does sharing work in TasGrid?",
|
"How does sharing work in TasGrid?",
|
||||||
"What is the difference between Handoff and Collaborative?",
|
"What is the difference between Handoff and Collaborative?",
|
||||||
@@ -33,7 +19,7 @@ const SUGGESTED_PROMPTS = [
|
|||||||
"How do recurrence and templates behave?"
|
"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<{
|
export const AIHelpPanel: Component<{
|
||||||
variant?: "page" | "sidebar";
|
variant?: "page" | "sidebar";
|
||||||
@@ -48,7 +34,10 @@ export const AIHelpPanel: Component<{
|
|||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
let messagesViewport: HTMLDivElement | undefined;
|
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(() => {
|
createEffect(() => {
|
||||||
messages();
|
messages();
|
||||||
@@ -79,7 +68,7 @@ export const AIHelpPanel: Component<{
|
|||||||
...nextMessages,
|
...nextMessages,
|
||||||
{
|
{
|
||||||
role: "assistant",
|
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);
|
setIsLoading(false);
|
||||||
@@ -87,39 +76,7 @@ export const AIHelpPanel: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(FIREWORKS_API_URL, {
|
const assistantText = await askAIHelp(nextMessages);
|
||||||
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.");
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
|
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
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"
|
isSidebar() ? "px-3 py-3" : "px-4 py-4 sm:px-5"
|
||||||
)}>
|
)}>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<Bot size={16} class="text-primary shrink-0" />
|
<MessageCircle size={16} class="text-primary shrink-0" />
|
||||||
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>AI Help</h1>
|
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>Ask Questions</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -177,32 +134,12 @@ export const AIHelpPanel: Component<{
|
|||||||
"flex min-h-0 flex-1 flex-col",
|
"flex min-h-0 flex-1 flex-col",
|
||||||
isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5"
|
isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5"
|
||||||
)}>
|
)}>
|
||||||
<Show
|
<Show when={!hasApiKey()}>
|
||||||
when={hasApiKey()}
|
<div class={cn(
|
||||||
fallback={
|
"rounded-2xl border border-amber-500/30 bg-amber-500/10",
|
||||||
<div class={cn(
|
isSidebar() ? "p-3 text-[0.75rem]" : "p-4 text-sm"
|
||||||
"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 Ask Questions. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
|
||||||
)}>
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -215,24 +152,51 @@ export const AIHelpPanel: Component<{
|
|||||||
>
|
>
|
||||||
<For each={messages()}>
|
<For each={messages()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<div class={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
|
<div class="space-y-2">
|
||||||
<div
|
<div class={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
|
||||||
class={cn(
|
<div
|
||||||
"rounded-2xl px-4 py-3 leading-relaxed whitespace-pre-wrap shadow-sm",
|
class={cn(
|
||||||
isSidebar() ? "max-w-[92%] text-[0.75rem]" : "max-w-[90%] sm:max-w-[80%] text-sm",
|
"rounded-2xl px-4 py-3 leading-relaxed whitespace-pre-wrap shadow-sm",
|
||||||
message.role === "user"
|
isSidebar() ? "max-w-[92%]" : "max-w-[90%] sm:max-w-[80%]",
|
||||||
? "bg-primary text-primary-foreground"
|
message.role === "user"
|
||||||
: "bg-card border border-border text-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
)}
|
: "bg-card border border-border text-foreground"
|
||||||
>
|
)}
|
||||||
<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>}>
|
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
|
||||||
<Bot size={12} />
|
<Show when={message.role === "assistant"} fallback={<span>You</span>}>
|
||||||
<span>Guide Assistant</span>
|
<MessageCircle size={12} />
|
||||||
</Show>
|
<span>TasGrid Help</span>
|
||||||
|
</Show>
|
||||||
|
</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>
|
||||||
<div>{message.content}</div>
|
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -241,15 +205,15 @@ export const AIHelpPanel: Component<{
|
|||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
<div class={cn(
|
<div class={cn(
|
||||||
"rounded-2xl px-4 py-3 leading-relaxed shadow-sm bg-card border border-border text-foreground",
|
"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">
|
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
|
||||||
<Bot size={12} />
|
<MessageCircle size={12} />
|
||||||
<span>Guide Assistant</span>
|
<span>TasGrid Help</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-muted-foreground">
|
<div class="flex items-center gap-2 text-muted-foreground">
|
||||||
<LoaderCircle size={14} class="animate-spin" />
|
<LoaderCircle size={14} class="animate-spin" />
|
||||||
Looking through the guide...
|
Finding an answer...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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..."
|
placeholder="Ask how a TasGrid feature works, what a setting does, or why something may behave a certain way..."
|
||||||
class={cn(
|
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",
|
"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(
|
<div class={cn(
|
||||||
@@ -295,7 +259,7 @@ export const AIHelpPanel: Component<{
|
|||||||
disabled={isLoading() || !input().trim()}
|
disabled={isLoading() || !input().trim()}
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
Ask AI Help
|
Ask Question
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface LayoutProps {
|
|||||||
setView: (v: string) => void;
|
setView: (v: string) => void;
|
||||||
isAIHelpOpen?: boolean;
|
isAIHelpOpen?: boolean;
|
||||||
onToggleAIHelp?: () => void;
|
onToggleAIHelp?: () => void;
|
||||||
|
aiHelpTip?: string | null;
|
||||||
hideQuickEntry?: boolean;
|
hideQuickEntry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
|||||||
setView={props.setView}
|
setView={props.setView}
|
||||||
isAIHelpOpen={!!props.isAIHelpOpen}
|
isAIHelpOpen={!!props.isAIHelpOpen}
|
||||||
onToggleAIHelp={() => props.onToggleAIHelp?.()}
|
onToggleAIHelp={() => props.onToggleAIHelp?.()}
|
||||||
|
aiHelpTip={props.aiHelpTip}
|
||||||
isLocked={isSidebarLocked()}
|
isLocked={isSidebarLocked()}
|
||||||
setIsLocked={setIsSidebarLocked}
|
setIsLocked={setIsSidebarLocked}
|
||||||
isPeeking={isSidebarPeeking()}
|
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 { 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 { cn } from "@/lib/utils";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
@@ -42,7 +42,6 @@ const mobileNavGroups: MobileNavGroup[] = [
|
|||||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ icon: Bot, label: "AI Help", view: "help_ai" },
|
|
||||||
{
|
{
|
||||||
icon: Gauge, label: "Flow", items: [
|
icon: Gauge, label: "Flow", items: [
|
||||||
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
||||||
@@ -50,6 +49,7 @@ const mobileNavGroups: MobileNavGroup[] = [
|
|||||||
{ icon: BarChart3, label: "Progress", view: "progress" },
|
{ icon: BarChart3, label: "Progress", view: "progress" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{ icon: MessageCircle, label: "Ask", view: "help_ai" },
|
||||||
{ icon: Settings, label: "Settings", view: "settings" },
|
{ icon: Settings, label: "Settings", view: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -306,6 +306,7 @@ export const Sidebar: Component<{
|
|||||||
setView: (v: string) => void;
|
setView: (v: string) => void;
|
||||||
isAIHelpOpen: boolean;
|
isAIHelpOpen: boolean;
|
||||||
onToggleAIHelp: () => void;
|
onToggleAIHelp: () => void;
|
||||||
|
aiHelpTip?: string | null;
|
||||||
isLocked: boolean;
|
isLocked: boolean;
|
||||||
setIsLocked: (v: boolean) => void;
|
setIsLocked: (v: boolean) => void;
|
||||||
isPeeking: boolean;
|
isPeeking: boolean;
|
||||||
@@ -356,7 +357,17 @@ export const Sidebar: Component<{
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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">
|
<nav class="flex-1 px-3 space-y-1 mt-2 min-h-0">
|
||||||
<For each={desktopNavItems}>
|
<For each={desktopNavItems}>
|
||||||
@@ -387,34 +398,28 @@ export const Sidebar: Component<{
|
|||||||
<AIHelpPanel variant="sidebar" class="h-[32rem]" />
|
<AIHelpPanel variant="sidebar" class="h-[32rem]" />
|
||||||
</div>
|
</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">
|
<div class="border-t border-border pt-3 space-y-1">
|
||||||
<button
|
<button
|
||||||
onClick={props.onToggleAIHelp}
|
onClick={props.onToggleAIHelp}
|
||||||
class={cn(
|
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
|
props.isAIHelpOpen
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bot size={16} class={cn("transition-transform group-hover:scale-110")} />
|
<MessageCircle size={16} class={cn("transition-transform group-hover:scale-110 mt-0.5 shrink-0")} />
|
||||||
<span class="font-medium">AI Help</span>
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={() => props.setView("help")}
|
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,
|
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
|
||||||
subscribedBuckets: store.subscribedBuckets,
|
subscribedBuckets: store.subscribedBuckets,
|
||||||
supervisorUserIds: store.personalContextSupervisorIds,
|
supervisorUserIds: store.personalContextSupervisorIds,
|
||||||
|
aiHelpTipHistory: store.aiHelpTipHistory,
|
||||||
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
|
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,3 +175,15 @@ export const removeNoteFilterTemplate = async (id: string) => {
|
|||||||
export const clearNoteFilter = () => {
|
export const clearNoteFilter = () => {
|
||||||
setStore("noteFilter", createDefaultFilter());
|
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[];
|
buckets: Bucket[];
|
||||||
subscribedBuckets: string[];
|
subscribedBuckets: string[];
|
||||||
personalContextSupervisorIds: string[];
|
personalContextSupervisorIds: string[];
|
||||||
|
aiHelpTipHistory: string[];
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
isNotepadMode: boolean;
|
isNotepadMode: boolean;
|
||||||
quickloadTasks: string[];
|
quickloadTasks: string[];
|
||||||
@@ -202,6 +203,7 @@ export interface ScopedTaskgridPrefs {
|
|||||||
noteFilter?: Filter;
|
noteFilter?: Filter;
|
||||||
subscribedBuckets?: string[];
|
subscribedBuckets?: string[];
|
||||||
supervisorUserIds?: string[];
|
supervisorUserIds?: string[];
|
||||||
|
aiHelpTipHistory?: string[];
|
||||||
dismissedTaskUpdateIndicators?: Record<string, string>;
|
dismissedTaskUpdateIndicators?: Record<string, string>;
|
||||||
collapsedUntilUpdatedTasks?: Record<string, string>;
|
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||||
migratedStructuredTagsV2?: boolean;
|
migratedStructuredTagsV2?: boolean;
|
||||||
@@ -246,6 +248,7 @@ export const [store, setStore] = createStore<TaskStore>({
|
|||||||
buckets: [],
|
buckets: [],
|
||||||
subscribedBuckets: [],
|
subscribedBuckets: [],
|
||||||
personalContextSupervisorIds: [],
|
personalContextSupervisorIds: [],
|
||||||
|
aiHelpTipHistory: [],
|
||||||
notes: [],
|
notes: [],
|
||||||
isNotepadMode: false,
|
isNotepadMode: false,
|
||||||
quickloadTasks: [],
|
quickloadTasks: [],
|
||||||
@@ -295,6 +298,7 @@ export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => {
|
|||||||
prefId: currentUserId,
|
prefId: currentUserId,
|
||||||
subscribedBuckets: prefs.subscribedBuckets || [],
|
subscribedBuckets: prefs.subscribedBuckets || [],
|
||||||
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
|
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
|
||||||
|
aiHelpTipHistory: prefs.aiHelpTipHistory || [],
|
||||||
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
||||||
filterTemplates: prefs.filterTemplates || [],
|
filterTemplates: prefs.filterTemplates || [],
|
||||||
quickloadTasks: prefs.quickloadTasks || [],
|
quickloadTasks: prefs.quickloadTasks || [],
|
||||||
|
|||||||
Reference in New Issue
Block a user