mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-10 14:58:02 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45d06f8854 | ||
|
|
fdc64c8fd6 | ||
|
|
8ae93940f3 | ||
|
|
cc5d232cfe | ||
|
|
a6e9d6ae92 | ||
|
|
e0b70d2d90 | ||
|
|
b3993238d5 | ||
|
|
5f5728ee8e | ||
|
|
6c5487609e | ||
|
|
79241d9335 | ||
|
|
2fedd1fd86 | ||
|
|
a8a8fa05c9 | ||
|
|
33130f2087 | ||
|
|
d5f84224eb | ||
|
|
14ab79835e | ||
|
|
4d0e1e7201 | ||
|
|
257721280f |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## v1.4.364 (2025-12-28)
|
||||
|
||||
### PR [#1907](https://github.com/danielmiessler/Fabric/pull/1907) by [majiayu000](https://github.com/majiayu000): feat(gui): add Session Name support for multi-turn conversations
|
||||
|
||||
- Added Session Name support for multi-turn conversations in GUI chat interface, enabling persistent conversations similar to CLI's --session flag
|
||||
- Added SessionName field to PromptRequest and sessionName to ChatPrompt interface for proper session handling
|
||||
- Extracted SessionSelector component with Select component instead of native dropdown for better user experience
|
||||
- Implemented session message loading when selecting existing sessions with proper error handling
|
||||
- Fixed Select component binding and empty input handling to prevent redundant API calls and properly clear sessions
|
||||
|
||||
## v1.4.363 (2025-12-25)
|
||||
|
||||
### PR [#1906](https://github.com/danielmiessler/Fabric/pull/1906) by [ksylvan](https://github.com/ksylvan): Code Quality: Optimize HTTP client reuse + simplify error formatting
|
||||
|
||||
- Refactor: optimize HTTP client reuse and simplify error formatting
|
||||
- Simplify error wrapping by removing redundant Sprintf calls in CLI
|
||||
- Pass HTTP client to FetchModelsDirectly to enable connection reuse
|
||||
- Store persistent HTTP client instance inside the OpenAI provider struct
|
||||
- Update compatible AI providers to match the new function signature
|
||||
|
||||
## v1.4.362 (2025-12-25)
|
||||
|
||||
### PR [#1904](https://github.com/danielmiessler/Fabric/pull/1904) by [majiayu000](https://github.com/majiayu000): fix: resolve WebUI tooltips not rendering due to overflow clipping
|
||||
|
||||
- Fix: resolve WebUI tooltips not rendering due to overflow clipping by using position: fixed and getBoundingClientRect() to calculate tooltip position dynamically, preventing tooltips from being clipped by parent containers with overflow: hidden
|
||||
- Refactor: extract tooltip positioning logic into separate positioning.ts module for better code organization and maintainability
|
||||
- Improve accessibility with aria-describedby attributes and unique IDs for better screen reader support
|
||||
- Add reactive tooltip position updates on scroll and resize events for dynamic positioning
|
||||
- Add SSR safety with isBrowser flag check and comprehensive unit test coverage for the positioning functions
|
||||
|
||||
## v1.4.361 (2025-12-25)
|
||||
|
||||
### PR [#1905](https://github.com/danielmiessler/Fabric/pull/1905) by [majiayu000](https://github.com/majiayu000): fix: optimize oversized logo images reducing package size by 93%
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package main
|
||||
|
||||
var version = "v1.4.361"
|
||||
var version = "v1.4.364"
|
||||
|
||||
Binary file not shown.
@@ -14,19 +14,19 @@ import (
|
||||
|
||||
func CopyToClipboard(message string) (err error) {
|
||||
if err = clipboard.WriteAll(message); err != nil {
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("could_not_copy_to_clipboard"), err))
|
||||
err = fmt.Errorf(i18n.T("could_not_copy_to_clipboard"), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func CreateOutputFile(message string, fileName string) (err error) {
|
||||
if _, err = os.Stat(fileName); err == nil {
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("file_already_exists_not_overwriting"), fileName))
|
||||
err = fmt.Errorf(i18n.T("file_already_exists_not_overwriting"), fileName)
|
||||
return
|
||||
}
|
||||
var file *os.File
|
||||
if file, err = os.Create(fileName); err != nil {
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("error_creating_file"), err))
|
||||
err = fmt.Errorf(i18n.T("error_creating_file"), err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
@@ -34,7 +34,7 @@ func CreateOutputFile(message string, fileName string) (err error) {
|
||||
message += "\n"
|
||||
}
|
||||
if _, err = file.WriteString(message); err != nil {
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("error_writing_to_file"), err))
|
||||
err = fmt.Errorf(i18n.T("error_writing_to_file"), err)
|
||||
} else {
|
||||
debuglog.Log("\n\n[Output also written to %s]\n", fileName)
|
||||
}
|
||||
@@ -51,13 +51,13 @@ func CreateAudioOutputFile(audioData []byte, fileName string) (err error) {
|
||||
// File existence check is now done in the CLI layer before TTS generation
|
||||
var file *os.File
|
||||
if file, err = os.Create(fileName); err != nil {
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("error_creating_audio_file"), err))
|
||||
err = fmt.Errorf(i18n.T("error_creating_audio_file"), err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err = file.Write(audioData); err != nil {
|
||||
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("error_writing_audio_data"), err))
|
||||
err = fmt.Errorf(i18n.T("error_writing_audio_data"), err)
|
||||
}
|
||||
// No redundant output message here - the CLI layer handles success messaging
|
||||
return
|
||||
|
||||
@@ -30,7 +30,8 @@ const maxResponseSize = 10 * 1024 * 1024 // 10MB
|
||||
// standard OpenAI SDK method fails due to a nonstandard format. This is useful
|
||||
// for providers that return a direct array of models (e.g., GitHub Models) or
|
||||
// other OpenAI-compatible implementations.
|
||||
func FetchModelsDirectly(ctx context.Context, baseURL, apiKey, providerName string) ([]string, error) {
|
||||
// If httpClient is nil, a new client with default settings will be created.
|
||||
func FetchModelsDirectly(ctx context.Context, baseURL, apiKey, providerName string, httpClient *http.Client) ([]string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -52,10 +53,12 @@ func FetchModelsDirectly(ctx context.Context, baseURL, apiKey, providerName stri
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
// TODO: Consider reusing a single http.Client instance (e.g., as a field on Client) instead of allocating a new one for
|
||||
// each request.
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
// Reuse provided HTTP client, or create a new one if not provided
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,8 +3,10 @@ package openai
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/chat"
|
||||
"github.com/danielmiessler/fabric/internal/domain"
|
||||
@@ -65,6 +67,7 @@ type Client struct {
|
||||
ApiBaseURL *plugins.SetupQuestion
|
||||
ApiClient *openai.Client
|
||||
ImplementsResponses bool // Whether this provider supports the Responses API
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// SetResponsesAPIEnabled configures whether to use the Responses API
|
||||
@@ -79,6 +82,11 @@ func (o *Client) configure() (ret error) {
|
||||
}
|
||||
client := openai.NewClient(opts...)
|
||||
o.ApiClient = &client
|
||||
|
||||
// Initialize HTTP client for direct API calls (reused across requests)
|
||||
o.httpClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,7 +104,7 @@ func (o *Client) ListModels() (ret []string, err error) {
|
||||
// Some providers (e.g., GitHub Models) return non-standard response formats
|
||||
// that the SDK fails to parse.
|
||||
debuglog.Debug(debuglog.Basic, "SDK Models.List failed for %s: %v, falling back to direct API fetch\n", o.GetName(), err)
|
||||
return FetchModelsDirectly(context.Background(), o.ApiBaseURL.Value, o.ApiKey.Value, o.GetName())
|
||||
return FetchModelsDirectly(context.Background(), o.ApiBaseURL.Value, o.ApiKey.Value, o.GetName(), o.httpClient)
|
||||
}
|
||||
|
||||
func (o *Client) SendStream(
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestFetchModelsDirectly_DirectArray(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider")
|
||||
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(models))
|
||||
assert.Equal(t, "github-model", models[0])
|
||||
@@ -36,7 +36,7 @@ func TestFetchModelsDirectly_OpenAIFormat(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider")
|
||||
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(models))
|
||||
assert.Equal(t, "openai-model", models[0])
|
||||
@@ -52,7 +52,7 @@ func TestFetchModelsDirectly_EmptyArray(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider")
|
||||
models, err := FetchModelsDirectly(context.Background(), srv.URL, "test-key", "TestProvider", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(models))
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ import (
|
||||
// DirectlyGetModels is used to fetch models directly from the API when the
|
||||
// standard OpenAI SDK method fails due to a nonstandard format.
|
||||
func (c *Client) DirectlyGetModels(ctx context.Context) ([]string, error) {
|
||||
return openai.FetchModelsDirectly(ctx, c.ApiBaseURL.Value, c.ApiKey.Value, c.GetName())
|
||||
return openai.FetchModelsDirectly(ctx, c.ApiBaseURL.Value, c.ApiKey.Value, c.GetName(), nil)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func (c *Client) ListModels() ([]string, error) {
|
||||
}
|
||||
// TODO: Handle context properly in Fabric by accepting and propagating a context.Context
|
||||
// instead of creating a new one here.
|
||||
return openai.FetchModelsDirectly(context.Background(), c.modelsURL, c.Client.ApiKey.Value, c.GetName())
|
||||
return openai.FetchModelsDirectly(context.Background(), c.modelsURL, c.Client.ApiKey.Value, c.GetName(), nil)
|
||||
}
|
||||
|
||||
// First try the standard OpenAI SDK approach
|
||||
|
||||
@@ -65,7 +65,9 @@ func (o *PatternsEntity) loadPattern(source string) (pattern *Pattern, err error
|
||||
}
|
||||
|
||||
// Use the resolved absolute path to get the pattern
|
||||
pattern, _ = o.getFromFile(absPath)
|
||||
if pattern, err = o.getFromFile(absPath); err != nil {
|
||||
return nil, fmt.Errorf("could not load pattern from file %s: %w", absPath, err)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, get the pattern from the database
|
||||
pattern, err = o.getFromDB(source)
|
||||
|
||||
@@ -29,6 +29,7 @@ type PromptRequest struct {
|
||||
ContextName string `json:"contextName"`
|
||||
PatternName string `json:"patternName"`
|
||||
StrategyName string `json:"strategyName"` // Optional strategy name
|
||||
SessionName string `json:"sessionName"` // Session name for multi-turn conversations
|
||||
Variables map[string]string `json:"variables,omitempty"` // Pattern variables
|
||||
}
|
||||
|
||||
@@ -131,6 +132,7 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
|
||||
},
|
||||
PatternName: p.PatternName,
|
||||
ContextName: p.ContextName,
|
||||
SessionName: p.SessionName, // Pass session name for multi-turn conversations
|
||||
PatternVariables: p.Variables, // Pass pattern variables
|
||||
Language: request.Language, // Pass the language field
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"1.4.361"
|
||||
"1.4.364"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import Patterns from "./Patterns.svelte";
|
||||
import Models from "./Models.svelte";
|
||||
import ModelConfig from "./ModelConfig.svelte";
|
||||
import SessionSelector from "./SessionSelector.svelte";
|
||||
import { Select } from "$lib/components/ui/select";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { languageStore } from '$lib/store/language-store';
|
||||
import { strategies, selectedStrategy, fetchStrategies } from '$lib/store/strategy-store';
|
||||
@@ -75,6 +75,7 @@
|
||||
{/each}
|
||||
</Select>
|
||||
</div>
|
||||
<SessionSelector />
|
||||
<div>
|
||||
<Label for="pattern-variables" class="text-xs text-white/70 mb-1 block">Pattern Variables (JSON)</Label>
|
||||
<textarea
|
||||
|
||||
82
web/src/lib/components/chat/SessionSelector.svelte
Normal file
82
web/src/lib/components/chat/SessionSelector.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { Select } from "$lib/components/ui/select";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { currentSession, setSession, messageStore } from '$lib/store/chat-store';
|
||||
import { sessionAPI, sessions } from '$lib/store/session-store';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let sessionInput = '';
|
||||
|
||||
$: sessionsList = $sessions?.map(s => s.Name) ?? [];
|
||||
|
||||
function handleSessionInput() {
|
||||
const trimmed = sessionInput.trim();
|
||||
if (trimmed) {
|
||||
setSession(trimmed);
|
||||
} else {
|
||||
// Clear session when input is empty
|
||||
sessionInput = '';
|
||||
setSession(null);
|
||||
}
|
||||
}
|
||||
|
||||
let previousSessionInput = '';
|
||||
|
||||
async function handleSessionSelect() {
|
||||
// If the placeholder option (empty value) is selected, restore to previous value
|
||||
if (!sessionInput) {
|
||||
sessionInput = previousSessionInput || $currentSession || '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if session hasn't changed
|
||||
if (sessionInput === $currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousSessionInput = sessionInput;
|
||||
setSession(sessionInput);
|
||||
|
||||
// Load the selected session's message history so the chat reflects prior context
|
||||
try {
|
||||
const messages = await sessionAPI.loadSessionMessages(sessionInput);
|
||||
messageStore.set(messages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await sessionAPI.loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
}
|
||||
sessionInput = $currentSession ?? '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Label for="session-input" class="text-xs text-white/70 mb-1 block">Session Name</Label>
|
||||
<input
|
||||
id="session-input"
|
||||
type="text"
|
||||
bind:value={sessionInput}
|
||||
on:blur={handleSessionInput}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleSessionInput()}
|
||||
placeholder="Enter session name..."
|
||||
class="w-full px-3 py-2 text-sm bg-primary-800/30 border-none rounded-md hover:bg-primary-800/40 transition-colors text-white placeholder-white/50 focus:ring-1 focus:ring-white/20 focus:outline-none"
|
||||
/>
|
||||
{#if sessionsList.length > 0}
|
||||
<Select
|
||||
bind:value={sessionInput}
|
||||
on:change={handleSessionSelect}
|
||||
class="mt-2 bg-primary-800/30 border-none hover:bg-primary-800/40 transition-colors"
|
||||
>
|
||||
<option value="">Load existing session...</option>
|
||||
{#each sessionsList as session}
|
||||
<option value={session}>{session}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,43 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { calculateTooltipPosition, formatPositionStyle, type TooltipPosition } from './positioning';
|
||||
|
||||
export let text: string;
|
||||
export let position: 'top' | 'bottom' | 'left' | 'right' = 'top';
|
||||
// biome-ignore lint/style/useConst: Svelte props must use 'let' even when not reassigned
|
||||
export let position: TooltipPosition = 'top';
|
||||
|
||||
let tooltipVisible = false;
|
||||
let tooltipElement: HTMLDivElement;
|
||||
// eslint-disable-next-line no-unassigned-vars -- Assigned via bind:this in template
|
||||
let triggerElement: HTMLDivElement;
|
||||
let isBrowser = false;
|
||||
// biome-ignore lint/correctness/noUnusedVariables: Used in template for aria-describedby and id
|
||||
const tooltipId = `tooltip-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Reactive tooltip positioning - recalculates when position or element changes
|
||||
$: tooltipStyle = triggerElement && tooltipVisible
|
||||
? formatPositionStyle(calculateTooltipPosition(triggerElement.getBoundingClientRect(), position))
|
||||
: '';
|
||||
|
||||
function updatePosition() {
|
||||
if (triggerElement && tooltipVisible) {
|
||||
tooltipStyle = formatPositionStyle(calculateTooltipPosition(triggerElement.getBoundingClientRect(), position));
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: Used in template event handlers
|
||||
function showTooltip() {
|
||||
tooltipVisible = true;
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: Used in template event handlers
|
||||
function hideTooltip() {
|
||||
tooltipVisible = false;
|
||||
}
|
||||
|
||||
// Handle window scroll and resize to keep tooltip positioned correctly
|
||||
// Only runs in browser (not during SSR)
|
||||
onMount(() => {
|
||||
isBrowser = true;
|
||||
return () => {
|
||||
if (isBrowser) {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Add/remove event listeners reactively when tooltip visibility changes
|
||||
$: if (isBrowser && tooltipVisible) {
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
} else if (isBrowser && !tooltipVisible) {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions a11y-mouse-events-have-key-events -->
|
||||
<div class="tooltip-container">
|
||||
<div
|
||||
<div
|
||||
bind:this={triggerElement}
|
||||
class="tooltip-trigger"
|
||||
on:mouseenter={showTooltip}
|
||||
on:mouseleave={hideTooltip}
|
||||
on:focusin={showTooltip}
|
||||
on:focusout={hideTooltip}
|
||||
role="tooltip"
|
||||
aria-label="Tooltip trigger"
|
||||
aria-describedby={tooltipVisible ? tooltipId : undefined}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
|
||||
{#if tooltipVisible}
|
||||
<div
|
||||
bind:this={tooltipElement}
|
||||
class="tooltip absolute z-[9999] px-2 py-1 text-xs rounded bg-gray-900/90 text-white whitespace-nowrap shadow-lg backdrop-blur-sm"
|
||||
id={tooltipId}
|
||||
class="tooltip fixed z-[9999] px-2 py-1 text-xs rounded bg-gray-900/90 text-white whitespace-nowrap shadow-lg backdrop-blur-sm"
|
||||
class:top="{position === 'top'}"
|
||||
class:bottom="{position === 'bottom'}"
|
||||
class:left="{position === 'left'}"
|
||||
class:right="{position === 'right'}"
|
||||
style={tooltipStyle}
|
||||
role="tooltip"
|
||||
aria-label={text}
|
||||
>
|
||||
{text}
|
||||
<div class="tooltip-arrow" role="presentation" />
|
||||
@@ -57,32 +100,24 @@
|
||||
|
||||
.tooltip {
|
||||
pointer-events: none;
|
||||
transition: all 150ms ease-in-out;
|
||||
transition: opacity 150ms ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip.top {
|
||||
bottom: calc(100% + 5px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
.tooltip.bottom {
|
||||
top: calc(100% + 5px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.tooltip.left {
|
||||
right: calc(100% + 5px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
transform: translate(-100%, -50%);
|
||||
}
|
||||
|
||||
.tooltip.right {
|
||||
left: calc(100% + 5px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
|
||||
56
web/src/lib/components/ui/tooltip/Tooltip.test.ts
Normal file
56
web/src/lib/components/ui/tooltip/Tooltip.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculateTooltipPosition, formatPositionStyle, TOOLTIP_GAP } from './positioning';
|
||||
|
||||
describe('Tooltip positioning logic', () => {
|
||||
const mockRect = {
|
||||
top: 100,
|
||||
bottom: 130,
|
||||
left: 200,
|
||||
right: 300,
|
||||
width: 100,
|
||||
height: 30,
|
||||
x: 200,
|
||||
y: 100,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect;
|
||||
|
||||
it('calculates top position correctly', () => {
|
||||
const result = calculateTooltipPosition(mockRect, 'top');
|
||||
expect(result.top).toBe(92); // 100 - 8
|
||||
expect(result.left).toBe(250); // 200 + 100/2
|
||||
});
|
||||
|
||||
it('calculates bottom position correctly', () => {
|
||||
const result = calculateTooltipPosition(mockRect, 'bottom');
|
||||
expect(result.top).toBe(138); // 130 + 8
|
||||
expect(result.left).toBe(250); // 200 + 100/2
|
||||
});
|
||||
|
||||
it('calculates left position correctly', () => {
|
||||
const result = calculateTooltipPosition(mockRect, 'left');
|
||||
expect(result.top).toBe(115); // 100 + 30/2
|
||||
expect(result.left).toBe(192); // 200 - 8
|
||||
});
|
||||
|
||||
it('calculates right position correctly', () => {
|
||||
const result = calculateTooltipPosition(mockRect, 'right');
|
||||
expect(result.top).toBe(115); // 100 + 30/2
|
||||
expect(result.left).toBe(308); // 300 + 8
|
||||
});
|
||||
|
||||
it('uses the correct gap value', () => {
|
||||
expect(TOOLTIP_GAP).toBe(8);
|
||||
});
|
||||
|
||||
it('formats position style correctly', () => {
|
||||
const position = { top: 100, left: 200 };
|
||||
const style = formatPositionStyle(position);
|
||||
expect(style).toBe('top: 100px; left: 200px;');
|
||||
});
|
||||
|
||||
it('respects custom gap parameter', () => {
|
||||
const customGap = 16;
|
||||
const result = calculateTooltipPosition(mockRect, 'top', customGap);
|
||||
expect(result.top).toBe(84); // 100 - 16
|
||||
});
|
||||
});
|
||||
27
web/src/lib/components/ui/tooltip/positioning.ts
Normal file
27
web/src/lib/components/ui/tooltip/positioning.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const TOOLTIP_GAP = 8;
|
||||
|
||||
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
export interface Position {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export function calculateTooltipPosition(
|
||||
rect: DOMRect,
|
||||
position: TooltipPosition,
|
||||
gap: number = TOOLTIP_GAP
|
||||
): Position {
|
||||
const positions: Record<TooltipPosition, Position> = {
|
||||
top: { top: rect.top - gap, left: rect.left + rect.width / 2 },
|
||||
bottom: { top: rect.bottom + gap, left: rect.left + rect.width / 2 },
|
||||
left: { top: rect.top + rect.height / 2, left: rect.left - gap },
|
||||
right: { top: rect.top + rect.height / 2, left: rect.right + gap }
|
||||
};
|
||||
|
||||
return positions[position];
|
||||
}
|
||||
|
||||
export function formatPositionStyle(position: Position): string {
|
||||
return `top: ${position.top}px; left: ${position.left}px;`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface ChatPrompt {
|
||||
model: string;
|
||||
patternName?: string;
|
||||
strategyName?: string; // Optional strategy name to prepend strategy prompt
|
||||
sessionName?: string; // Session name for multi-turn conversations
|
||||
variables?: { [key: string]: string }; // Pattern variables
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
systemPrompt,
|
||||
} from "$lib/store/pattern-store";
|
||||
import { selectedStrategy } from "$lib/store/strategy-store";
|
||||
import { currentSession } from "$lib/store/chat-store";
|
||||
|
||||
class LanguageValidator {
|
||||
constructor(private targetLanguage: string) {}
|
||||
@@ -210,6 +211,7 @@ export class ChatService {
|
||||
model: config.model,
|
||||
patternName: get(selectedPatternName),
|
||||
strategyName: get(selectedStrategy), // Add selected strategy to prompt
|
||||
sessionName: get(currentSession) ?? undefined, // Session name for multi-turn conversations
|
||||
variables: get(patternVariables), // Add pattern variables
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,5 +89,20 @@ export const sessionAPI = {
|
||||
toastService.error(error instanceof Error ? error.message : 'Failed to import session');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async loadSessionMessages(sessionName: string): Promise<Message[]> {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionName}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load session: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const messages = Array.isArray(data.Message) ? data.Message : [];
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error(`Error loading session messages for ${sessionName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user