mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-09 22:38:10 -05:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d8b46b878 | ||
|
|
fbd6083079 | ||
|
|
0320e17652 | ||
|
|
09fb913279 | ||
|
|
ec5ed689bb | ||
|
|
43ca0dccf7 | ||
|
|
fcfcf55610 | ||
|
|
188235efc5 | ||
|
|
fdd1d614b2 | ||
|
|
fc67dea243 | ||
|
|
efd363d5fb | ||
|
|
a7d6de1661 | ||
|
|
c0ade48648 | ||
|
|
7fd4fa4742 | ||
|
|
41b2e66c5c | ||
|
|
ed657383fb | ||
|
|
4d5d8d8b30 | ||
|
|
e9a75528ab | ||
|
|
c5ec4b548a | ||
|
|
8e87529638 | ||
|
|
ca33208fa1 | ||
|
|
3f8bca8728 | ||
|
|
ba56c33cf6 | ||
|
|
6ee4fdd366 | ||
|
|
30af189ae3 | ||
|
|
be998ff588 | ||
|
|
6bb3238e6d | ||
|
|
dfcd29593d | ||
|
|
63b357168e | ||
|
|
317a4309f7 | ||
|
|
eceb10b725 | ||
|
|
34f508fd82 | ||
|
|
9fa8634083 | ||
|
|
a3ea63c1f9 | ||
|
|
097b3eb0ba | ||
|
|
30f37ea633 | ||
|
|
23b495c8f7 | ||
|
|
e7f2d48437 | ||
|
|
7043f78f1f | ||
|
|
f2cc718f49 | ||
|
|
edb814c9f0 | ||
|
|
21de69b7d9 |
@@ -63,6 +63,10 @@ jobs:
|
||||
- name: Update version.nix file
|
||||
run: |
|
||||
echo "\"${{ env.new_version }}\"" > pkgs/fabric/version.nix
|
||||
|
||||
- name: Format source codes
|
||||
run: |
|
||||
go fmt ./...
|
||||
|
||||
- name: Update gomod2nix.toml file
|
||||
run: |
|
||||
@@ -73,6 +77,7 @@ jobs:
|
||||
git add version.go
|
||||
git add pkgs/fabric/version.nix
|
||||
git add gomod2nix.toml
|
||||
git add .
|
||||
if ! git diff --staged --quiet; then
|
||||
git commit -m "Update version to ${{ env.new_tag }} and commit $commit_hash"
|
||||
else
|
||||
|
||||
168
.gitignore
vendored
168
.gitignore
vendored
@@ -22,7 +22,7 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
@@ -166,3 +166,169 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
web/node_modules
|
||||
|
||||
# Output
|
||||
web/.output
|
||||
web/.vercel
|
||||
web/.svelte-kit
|
||||
web/build
|
||||
|
||||
# OS
|
||||
web/.DS_Store
|
||||
web/Thumbs.db
|
||||
|
||||
# Env
|
||||
web/.env
|
||||
web/.env.*
|
||||
web/!.env.example
|
||||
web/!.env.test
|
||||
|
||||
# Vite
|
||||
web/vite.config.js.timestamp-*
|
||||
web/vite.config.ts.timestamp-*
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
web/logs
|
||||
web/*.log
|
||||
web/npm-debug.log*
|
||||
web/yarn-debug.log*
|
||||
web/yarn-error.log*
|
||||
web/lerna-debug.log*
|
||||
web/.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
web/report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
web/pids
|
||||
web/*.pid
|
||||
web/*.seed
|
||||
web/*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
web/lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
web/coverage
|
||||
web/*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
web/.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
web/.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
web/bower_components
|
||||
|
||||
# node-waf configuration
|
||||
web/.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
web/node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web/web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
web/.npm
|
||||
|
||||
# Optional eslint cache
|
||||
web/.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
web/.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
web/.rpt2_cache/
|
||||
web/.rts2_cache_cjs/
|
||||
web/.rts2_cache_es/
|
||||
web/.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
web/.env
|
||||
web/.env.development.local
|
||||
web/.env.test.local
|
||||
web/.env.production.local
|
||||
web/.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
web/.next
|
||||
web/out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
web/.nuxt
|
||||
web/dist
|
||||
|
||||
# Gatsby files
|
||||
web/.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
web/.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
web/.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
web/.vscode-test
|
||||
|
||||
# yarn v2
|
||||
web/.yarn/cache
|
||||
web/.yarn/unplugged
|
||||
web/.yarn/build-state.yml
|
||||
web/.yarn/install-state.gz
|
||||
web/.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
web/.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
web/.svelte-kit
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node
|
||||
|
||||
|
||||
58
README.md
58
README.md
@@ -26,6 +26,7 @@
|
||||
[Meta](#meta)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## Navigation
|
||||
@@ -56,6 +57,7 @@
|
||||
- [`to_pdf`](#to_pdf)
|
||||
- [`to_pdf` Installation](#to_pdf-installation)
|
||||
- [pbpaste](#pbpaste)
|
||||
- [Web Interface](#Web_Interface)
|
||||
- [Meta](#meta)
|
||||
- [Primary contributors](#primary-contributors)
|
||||
|
||||
@@ -64,9 +66,9 @@
|
||||
## Updates
|
||||
|
||||
> [!NOTE]
|
||||
November 8, 2024
|
||||
> * **Multimodal Support**: You can now us `-a` (attachment) for Multimodal submissions to OpenAI models that support it. Example: `fabric -a https://path/to/image "Give me a description of this image."`
|
||||
|
||||
> November 8, 2024
|
||||
>
|
||||
> - **Multimodal Support**: You can now us `-a` (attachment) for Multimodal submissions to OpenAI models that support it. Example: `fabric -a https://path/to/image "Give me a description of this image."`
|
||||
|
||||
## What and why
|
||||
|
||||
@@ -82,10 +84,10 @@ Fabric was created to address this by enabling everyone to granularly apply AI t
|
||||
|
||||
Keep in mind that many of these were recorded when Fabric was Python-based, so remember to use the current [install instructions](#Installation) below.
|
||||
|
||||
* [Network Chuck](https://www.youtube.com/watch?v=UbDyjIIGaxQ)
|
||||
* [David Bombal](https://www.youtube.com/watch?v=vF-MQmVxnCs)
|
||||
* [My Own Intro to the Tool](https://www.youtube.com/watch?v=wPEyyigh10g)
|
||||
* [More Fabric YouTube Videos](https://www.youtube.com/results?search_query=fabric+ai)
|
||||
- [Network Chuck](https://www.youtube.com/watch?v=UbDyjIIGaxQ)
|
||||
- [David Bombal](https://www.youtube.com/watch?v=vF-MQmVxnCs)
|
||||
- [My Own Intro to the Tool](https://www.youtube.com/watch?v=wPEyyigh10g)
|
||||
- [More Fabric YouTube Videos](https://www.youtube.com/results?search_query=fabric+ai)
|
||||
|
||||
## Philosophy
|
||||
|
||||
@@ -155,6 +157,7 @@ go install github.com/danielmiessler/fabric@latest
|
||||
You may need to set some environment variables in your `~/.bashrc` on linux or `~/.zshrc` file on mac to be able to run the `fabric` command. Here is an example of what you can add:
|
||||
|
||||
For Intel based macs or linux
|
||||
|
||||
```bash
|
||||
# Golang environment variables
|
||||
export GOROOT=/usr/local/go
|
||||
@@ -165,6 +168,7 @@ export PATH=$GOPATH/bin:$GOROOT/bin:$HOME/.local/bin:$PATH
|
||||
```
|
||||
|
||||
for Apple Silicon based macs
|
||||
|
||||
```bash
|
||||
# Golang environment variables
|
||||
export GOROOT=$(brew --prefix go)/libexec
|
||||
@@ -173,14 +177,18 @@ export PATH=$GOPATH/bin:$GOROOT/bin:$HOME/.local/bin:$PATH
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
Now run the following command
|
||||
|
||||
```bash
|
||||
# Run the setup to set up your directories and keys
|
||||
fabric --setup
|
||||
```
|
||||
|
||||
If everything works you are good to go.
|
||||
|
||||
### Add aliases for all patterns
|
||||
|
||||
In order to add aliases for all your patterns and use them directly as commands ie. `summarize` instead of `fabric --pattern summarize`
|
||||
You can add the following to your `.zshrc` or `.bashrc` file.
|
||||
|
||||
@@ -189,10 +197,10 @@ You can add the following to your `.zshrc` or `.bashrc` file.
|
||||
for pattern_file in $HOME/.config/fabric/patterns/*; do
|
||||
# Get the base name of the file (i.e., remove the directory path)
|
||||
pattern_name=$(basename "$pattern_file")
|
||||
|
||||
|
||||
# Create an alias in the form: alias pattern_name="fabric --pattern pattern_name"
|
||||
alias_command="alias $pattern_name='fabric --pattern $pattern_name'"
|
||||
|
||||
|
||||
# Evaluate the alias command to add it to the current shell
|
||||
eval "$alias_command"
|
||||
done
|
||||
@@ -202,9 +210,11 @@ yt() {
|
||||
fabric -y "$video_link" --transcript
|
||||
}
|
||||
```
|
||||
|
||||
This also creates a `yt` alias that allows you to use `yt https://www.youtube.com/watch?v=4b0iet22VIk` to get your transcripts.
|
||||
|
||||
#### Save your files in markdown using aliases
|
||||
|
||||
If in addition to the above aliases you would like to have the option to save the output to your favourite markdown note vault like Obsidian then instead of the above add the following to your `.zshrc` or `.bashrc` file:
|
||||
|
||||
```bash
|
||||
@@ -244,7 +254,7 @@ yt() {
|
||||
}
|
||||
```
|
||||
|
||||
This will allow you to use the patterns as aliases like in the above for example `summarize` instead of `fabric --pattern summarize --stream`, however if you pass in an extra argument like this `summarize "my_article_title"` your output will be saved in the destination that you set in `obsidian_base="/path/to/obsidian"` in the following format `YYYY-MM-DD-my_article_title.md` where the date gets autogenerated for you.
|
||||
This will allow you to use the patterns as aliases like in the above for example `summarize` instead of `fabric --pattern summarize --stream`, however if you pass in an extra argument like this `summarize "my_article_title"` your output will be saved in the destination that you set in `obsidian_base="/path/to/obsidian"` in the following format `YYYY-MM-DD-my_article_title.md` where the date gets autogenerated for you.
|
||||
You can tweak the date format by tweaking the `date_stamp` format.
|
||||
|
||||
### Migration
|
||||
@@ -268,11 +278,13 @@ Then [set your environmental variables](#environmental-variables) as shown above
|
||||
### Upgrading
|
||||
|
||||
The great thing about Go is that it's super easy to upgrade. Just run the same command you used to install it in the first place and you'll always get the latest version.
|
||||
|
||||
```bash
|
||||
go install github.com/danielmiessler/fabric@latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once you have it all set up, here's how to use it.
|
||||
|
||||
```bash
|
||||
@@ -320,6 +332,7 @@ Application Options:
|
||||
--printcontext= Print context
|
||||
--printsession= Print session
|
||||
--readability Convert HTML input into a clean, readable view
|
||||
--serve Initiate the API server
|
||||
--dry-run Show what would be sent to the model without actually sending it
|
||||
--version Print current version
|
||||
|
||||
@@ -401,7 +414,6 @@ When you're ready to use them, copy them into:
|
||||
|
||||
You can then use them like any other Patterns, but they won't be public unless you explicitly submit them as Pull Requests to the Fabric project. So don't worry—they're private to you.
|
||||
|
||||
|
||||
This feature works with all openai and ollama models but does NOT work with claude. You can specify your model with the -m flag
|
||||
|
||||
## Helper Apps
|
||||
@@ -459,6 +471,29 @@ You can also create an alias by editing `~/.bashrc` or `~/.zshrc` and adding the
|
||||
alias pbpaste='xclip -selection clipboard -o'
|
||||
```
|
||||
|
||||
## Web Interface
|
||||
|
||||
Fabric now includes a built-in web interface that provides a GUI alternative to the command-line interface and an out-of-the-box website for those who want to get started with web development or blogging.
|
||||
You can use this app as a GUI interface for Fabric, a ready to go blog-site, or a website template for your own projects.
|
||||
|
||||
The `web/src/lib/content` directory includes starter `.obsidian/` and `templates/` directories, allowing you to open up the `web/src/lib/content/` directory as an [Obsidian.md](https://obsidian.md) vault. You can place your posts in the posts directory when you're ready to publish.
|
||||
### Installing
|
||||
|
||||
The GUI can be installed by navigating to the `web` directory and using `npm install`, `pnpm install`, or your favorite package manager. Then simply run the development server to start the app.
|
||||
|
||||
_You will need to run fabric in a separate terminal with the `fabric --serve` command._
|
||||
|
||||
**From the fabric project `web/` directory:**
|
||||
```shell
|
||||
npm run dev
|
||||
|
||||
## or ##
|
||||
|
||||
pnpm run dev
|
||||
|
||||
## or your equivalent
|
||||
```
|
||||
|
||||
## Meta
|
||||
|
||||
> [!NOTE]
|
||||
@@ -467,6 +502,7 @@ alias pbpaste='xclip -selection clipboard -o'
|
||||
- _Jonathan Dunn_ for being the absolute MVP dev on the project, including spearheading the new Go version, as well as the GUI! All this while also being a full-time medical doctor!
|
||||
- _Caleb Sima_ for pushing me over the edge of whether to make this a public project or not.
|
||||
- _Eugen Eisler_ and _Frederick Ros_ for their invaluable contributions to the Go version
|
||||
- _David Peters_ for his work on the web interface.
|
||||
- _Joel Parish_ for super useful input on the project's Github directory structure..
|
||||
- _Joseph Thacker_ for the idea of a `-c` context flag that adds pre-created context in the `./config/fabric/` directory to all Pattern queries.
|
||||
- _Jason Haddix_ for the idea of a stitch (chained Pattern) to filter content using a local model before sending on to a cloud model, i.e., cleaning customer data using `llama2` before sending on to `gpt-4` for analysis.
|
||||
|
||||
@@ -51,6 +51,7 @@ func Cli(version string) (err error) {
|
||||
}
|
||||
|
||||
if currentFlags.Serve {
|
||||
registry.ConfigureVendors()
|
||||
err = restapi.Serve(registry, currentFlags.ServeAddress)
|
||||
return
|
||||
}
|
||||
|
||||
20
cli/flags.go
20
cli/flags.go
@@ -63,8 +63,6 @@ type Flags struct {
|
||||
|
||||
// Init Initialize flags. returns a Flags struct and an error
|
||||
func Init() (ret *Flags, err error) {
|
||||
var message string
|
||||
|
||||
ret = &Flags{}
|
||||
parser := flags.NewParser(ret, flags.Default)
|
||||
var args []string
|
||||
@@ -75,21 +73,19 @@ func Init() (ret *Flags, err error) {
|
||||
info, _ := os.Stdin.Stat()
|
||||
pipedToStdin := (info.Mode() & os.ModeCharDevice) == 0
|
||||
|
||||
//custom message
|
||||
if len(args) > 0 {
|
||||
ret.Message = AppendMessage(ret.Message, args[len(args)-1])
|
||||
}
|
||||
|
||||
// takes input from stdin if it exists, otherwise takes input from args (the last argument)
|
||||
if pipedToStdin {
|
||||
//fmt.Printf("piped: %v\n", args)
|
||||
if message, err = readStdin(); err != nil {
|
||||
var pipedMessage string
|
||||
if pipedMessage, err = readStdin(); err != nil {
|
||||
return
|
||||
}
|
||||
} else if len(args) > 0 {
|
||||
//fmt.Printf("no piped: %v\n", args)
|
||||
message = args[len(args)-1]
|
||||
} else {
|
||||
//fmt.Printf("no data: %v\n", args)
|
||||
message = ""
|
||||
ret.Message = AppendMessage(ret.Message, pipedMessage)
|
||||
}
|
||||
ret.Message = message
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
73
common/utils.go
Normal file
73
common/utils.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetAbsolutePath resolves a given path to its absolute form, handling ~, ./, ../, UNC paths, and symlinks.
|
||||
func GetAbsolutePath(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", errors.New("path is empty")
|
||||
}
|
||||
|
||||
// Handle UNC paths on Windows
|
||||
if runtime.GOOS == "windows" && strings.HasPrefix(path, `\\`) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Handle ~ for home directory expansion
|
||||
if strings.HasPrefix(path, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", errors.New("could not resolve home directory")
|
||||
}
|
||||
path = filepath.Join(home, path[1:])
|
||||
}
|
||||
|
||||
// Convert to absolute path
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", errors.New("could not get absolute path")
|
||||
}
|
||||
|
||||
// Resolve symlinks, but allow non-existent paths
|
||||
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err == nil {
|
||||
return resolvedPath, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
// Return the absolute path for non-existent paths
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not resolve symlinks: %w", err)
|
||||
}
|
||||
|
||||
// Helper function to check if a symlink points to a directory
|
||||
func IsSymlinkToDir(path string) bool {
|
||||
fileInfo, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if fileInfo.Mode()&os.ModeSymlink != 0 {
|
||||
resolvedPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
fileInfo, err = os.Stat(resolvedPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return fileInfo.IsDir()
|
||||
}
|
||||
|
||||
return false // Regular directories should not be treated as symlinks
|
||||
}
|
||||
@@ -31,6 +31,15 @@ func (o *Chatter) Send(request *common.ChatRequest, opts *common.ChatOptions) (s
|
||||
return
|
||||
}
|
||||
|
||||
vendorMessages := session.GetVendorMessages()
|
||||
if len(vendorMessages) == 0 {
|
||||
if session.Name != "" {
|
||||
err = o.db.Sessions.SaveSession(session)
|
||||
}
|
||||
err = fmt.Errorf("no messages provided")
|
||||
return
|
||||
}
|
||||
|
||||
if opts.Model == "" {
|
||||
opts.Model = o.model
|
||||
}
|
||||
@@ -73,7 +82,6 @@ func (o *Chatter) Send(request *common.ChatRequest, opts *common.ChatOptions) (s
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
func (o *Chatter) BuildSession(request *common.ChatRequest, raw bool) (session *fsdb.Session, err error) {
|
||||
// If a session name is provided, retrieve it from the database
|
||||
if request.SessionName != "" {
|
||||
@@ -102,29 +110,33 @@ func (o *Chatter) BuildSession(request *common.ChatRequest, raw bool) (session *
|
||||
contextContent = ctx.Content
|
||||
}
|
||||
|
||||
|
||||
// Process any template variables in the message content (user input)
|
||||
// Double curly braces {{variable}} indicate template substitution
|
||||
// should occur, whether in patterns or direct input
|
||||
if request.Message != nil {
|
||||
request.Message.Content, err = template.ApplyTemplate(request.Message.Content, request.PatternVariables, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Double curly braces {{variable}} indicate template substitution
|
||||
// Ensure we have a message before processing, other wise we'll get an error when we pass to pattern.go
|
||||
if request.Message == nil {
|
||||
request.Message = &goopenai.ChatCompletionMessage{
|
||||
Role: goopenai.ChatMessageRoleUser,
|
||||
Content: " ",
|
||||
}
|
||||
}
|
||||
|
||||
// Now we know request.Message is not nil, process template variables
|
||||
request.Message.Content, err = template.ApplyTemplate(request.Message.Content, request.PatternVariables, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var patternContent string
|
||||
if request.PatternName != "" {
|
||||
pattern, err := o.db.Patterns.GetApplyVariables(request.PatternName, request.PatternVariables, request.Message.Content)
|
||||
// pattrn will now contain user input, and all variables will be resolved, or errored
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get pattern %s: %v", request.PatternName, err)
|
||||
}
|
||||
patternContent = pattern.Pattern
|
||||
pattern, err := o.db.Patterns.GetApplyVariables(request.PatternName, request.PatternVariables, request.Message.Content)
|
||||
// pattrn will now contain user input, and all variables will be resolved, or errored
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get pattern %s: %v", request.PatternName, err)
|
||||
}
|
||||
patternContent = pattern.Pattern
|
||||
}
|
||||
|
||||
|
||||
systemMessage := strings.TrimSpace(contextContent) + strings.TrimSpace(patternContent)
|
||||
if request.Language != "" {
|
||||
systemMessage = fmt.Sprintf("%s. Please use the language '%s' for the output.", systemMessage, request.Language)
|
||||
@@ -133,7 +145,7 @@ func (o *Chatter) BuildSession(request *common.ChatRequest, raw bool) (session *
|
||||
if raw {
|
||||
if request.Message != nil {
|
||||
if systemMessage != "" {
|
||||
request.Message.Content = systemMessage
|
||||
request.Message.Content = systemMessage
|
||||
// system contains pattern which contains user input
|
||||
}
|
||||
} else {
|
||||
|
||||
20
patterns/create_newsletter_entry/system.md
Normal file
20
patterns/create_newsletter_entry/system.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Identity and Purpose
|
||||
You are a custom GPT designed to create newsletter sections in the style of Frontend Weekly.
|
||||
|
||||
# Step-by-Step Process:
|
||||
1. The user will provide article text.
|
||||
2. Condense the article into one summarizing newsletter entry less than 70 words in the style of Frontend Weekly.
|
||||
3. Generate a concise title for the entry, focus on the main idea or most important fact of the article
|
||||
|
||||
# Tone and Style Guidelines:
|
||||
* Third-Party Narration: The newsletter should sound like it’s being narrated by an outside observer, someone who is both knowledgeable, unbiased and calm. Focus on the facts or main opinions in the original article. Creates a sense of objectivity and adds a layer of professionalism.
|
||||
|
||||
* Concise: Maintain brevity and clarity. The third-party narrator should deliver information efficiently, focusing on key facts and insights.
|
||||
|
||||
# Output Instructions:
|
||||
Your final output should be a polished, newsletter-ready paragraph with a title line in bold followed by the summary paragraph.
|
||||
|
||||
# INPUT:
|
||||
|
||||
INPUT:
|
||||
|
||||
0
patterns/create_newsletter_entry/user.md
Normal file
0
patterns/create_newsletter_entry/user.md
Normal file
@@ -1 +1 @@
|
||||
"1.4.110"
|
||||
"1.4.119"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/danielmiessler/fabric/common"
|
||||
"github.com/danielmiessler/fabric/plugins/template"
|
||||
)
|
||||
|
||||
@@ -22,50 +23,56 @@ type Pattern struct {
|
||||
Pattern string
|
||||
}
|
||||
|
||||
// main entry point for getting patterns from any source
|
||||
func (o *PatternsEntity) GetApplyVariables(source string, variables map[string]string, input string) (*Pattern, error) {
|
||||
var pattern *Pattern
|
||||
var err error
|
||||
// GetApplyVariables main entry point for getting patterns from any source
|
||||
func (o *PatternsEntity) GetApplyVariables(
|
||||
source string, variables map[string]string, input string) (pattern *Pattern, err error) {
|
||||
|
||||
// Determine if this is a file path
|
||||
isFilePath := strings.HasPrefix(source, "\\") ||
|
||||
strings.HasPrefix(source, "/") ||
|
||||
strings.HasPrefix(source, "~") ||
|
||||
strings.HasPrefix(source, ".")
|
||||
|
||||
if isFilePath {
|
||||
pattern, err = o.getFromFile(source)
|
||||
} else {
|
||||
pattern, err = o.getFromDB(source)
|
||||
}
|
||||
// Determine if this is a file path
|
||||
isFilePath := strings.HasPrefix(source, "\\") ||
|
||||
strings.HasPrefix(source, "/") ||
|
||||
strings.HasPrefix(source, "~") ||
|
||||
strings.HasPrefix(source, ".")
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pattern, err = o.applyVariables(pattern, variables, input)
|
||||
if isFilePath {
|
||||
// Resolve the file path using GetAbsolutePath
|
||||
absPath, err := common.GetAbsolutePath(source)
|
||||
if err != nil {
|
||||
return nil, err // Return the error if applyVariables failed
|
||||
return nil, fmt.Errorf("could not resolve file path: %v", err)
|
||||
}
|
||||
return pattern, nil
|
||||
|
||||
// Use the resolved absolute path to get the pattern
|
||||
pattern, err = o.getFromFile(absPath)
|
||||
} else {
|
||||
// Otherwise, get the pattern from the database
|
||||
pattern, err = o.getFromDB(source)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply variables to the pattern
|
||||
err = o.applyVariables(pattern, variables, input)
|
||||
return
|
||||
}
|
||||
|
||||
func (o *PatternsEntity) applyVariables(
|
||||
pattern *Pattern, variables map[string]string, input string) (err error) {
|
||||
|
||||
func (o *PatternsEntity) applyVariables(pattern *Pattern, variables map[string]string, input string) (*Pattern, error) {
|
||||
// If {{input}} isn't in pattern, append it on new line
|
||||
if !strings.Contains(pattern.Pattern, "{{input}}") {
|
||||
if !strings.HasSuffix(pattern.Pattern, "\n") {
|
||||
pattern.Pattern += "\n"
|
||||
}
|
||||
pattern.Pattern += "{{input}}"
|
||||
if !strings.HasSuffix(pattern.Pattern, "\n") {
|
||||
pattern.Pattern += "\n"
|
||||
}
|
||||
pattern.Pattern += "{{input}}"
|
||||
}
|
||||
|
||||
result, err := template.ApplyTemplate(pattern.Pattern, variables, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var result string
|
||||
if result, err = template.ApplyTemplate(pattern.Pattern, variables, input); err != nil {
|
||||
return
|
||||
}
|
||||
pattern.Pattern = result
|
||||
return pattern, nil
|
||||
return
|
||||
}
|
||||
|
||||
// retrieves a pattern from the database by name
|
||||
@@ -103,29 +110,31 @@ func (o *PatternsEntity) PrintLatestPatterns(latestNumber int) (err error) {
|
||||
}
|
||||
|
||||
// reads a pattern from a file path and returns it
|
||||
func (o *PatternsEntity) getFromFile(pathStr string) (*Pattern, error) {
|
||||
func (o *PatternsEntity) getFromFile(pathStr string) (pattern *Pattern, err error) {
|
||||
// Handle home directory expansion
|
||||
if strings.HasPrefix(pathStr, "~/") {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get home directory: %v", err)
|
||||
}
|
||||
pathStr = filepath.Join(homedir, pathStr[2:])
|
||||
var homedir string
|
||||
if homedir, err = os.UserHomeDir(); err != nil {
|
||||
err = fmt.Errorf("could not get home directory: %v", err)
|
||||
return
|
||||
}
|
||||
pathStr = filepath.Join(homedir, pathStr[2:])
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(pathStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read pattern file %s: %v", pathStr, err)
|
||||
var content []byte
|
||||
if content, err = os.ReadFile(pathStr); err != nil {
|
||||
err = fmt.Errorf("could not read pattern file %s: %v", pathStr, err)
|
||||
return
|
||||
}
|
||||
|
||||
return &Pattern{
|
||||
Name: pathStr,
|
||||
Pattern: string(content),
|
||||
}, nil
|
||||
pattern = &Pattern{
|
||||
Name: pathStr,
|
||||
Pattern: string(content),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get required for Storage interface
|
||||
func (o *PatternsEntity) Get(name string) (*Pattern, error) {
|
||||
// Use GetPattern with no variables
|
||||
return o.GetApplyVariables(name, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func createTestPattern(t *testing.T, entity *PatternsEntity, name, content strin
|
||||
|
||||
func TestApplyVariables(t *testing.T) {
|
||||
entity := &PatternsEntity{}
|
||||
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern *Pattern
|
||||
@@ -79,15 +79,15 @@ func TestApplyVariables(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := entity.applyVariables(tt.pattern, tt.variables, tt.input)
|
||||
|
||||
err := entity.applyVariables(tt.pattern, tt.variables, tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, result.Pattern)
|
||||
assert.Equal(t, tt.want, tt.pattern.Pattern)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -117,15 +117,15 @@ func TestGetApplyVariables(t *testing.T) {
|
||||
want: "You are a reviewer.\ncheck this code",
|
||||
},
|
||||
{
|
||||
name: "pattern with missing variable",
|
||||
source: "test-pattern",
|
||||
name: "pattern with missing variable",
|
||||
source: "test-pattern",
|
||||
variables: map[string]string{},
|
||||
input: "test input",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-existent pattern",
|
||||
source: "non-existent",
|
||||
name: "non-existent pattern",
|
||||
source: "non-existent",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
@@ -133,14 +133,14 @@ func TestGetApplyVariables(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := entity.GetApplyVariables(tt.source, tt.variables, tt.input)
|
||||
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, result.Pattern)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,7 @@ func (o *Session) Append(messages ...*goopenai.ChatCompletionMessage) {
|
||||
}
|
||||
|
||||
func (o *Session) GetVendorMessages() (ret []*goopenai.ChatCompletionMessage) {
|
||||
if o.vendorMessages == nil {
|
||||
o.vendorMessages = []*goopenai.ChatCompletionMessage{}
|
||||
if len(o.vendorMessages) == 0 {
|
||||
for _, message := range o.Messages {
|
||||
o.appendVendorMessage(message)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/danielmiessler/fabric/common"
|
||||
)
|
||||
|
||||
type StorageEntity struct {
|
||||
@@ -26,37 +26,44 @@ func (o *StorageEntity) Configure() (err error) {
|
||||
|
||||
// GetNames finds all patterns in the patterns directory and enters the id, name, and pattern into a slice of Entry structs. it returns these entries or an error
|
||||
func (o *StorageEntity) GetNames() (ret []string, err error) {
|
||||
var entries []os.DirEntry
|
||||
if entries, err = os.ReadDir(o.Dir); err != nil {
|
||||
err = fmt.Errorf("could not read items from directory: %v", err)
|
||||
return
|
||||
// Resolve the directory path to an absolute path
|
||||
absDir, err := common.GetAbsolutePath(o.Dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve directory path: %v", err)
|
||||
}
|
||||
|
||||
if o.ItemIsDir {
|
||||
ret = lo.FilterMap(entries, func(item os.DirEntry, index int) (ret string, ok bool) {
|
||||
if ok = item.IsDir(); ok {
|
||||
ret = item.Name()
|
||||
// Read the directory entries
|
||||
var entries []os.DirEntry
|
||||
if entries, err = os.ReadDir(absDir); err != nil {
|
||||
return nil, fmt.Errorf("could not read items from directory: %v", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(absDir, entry.Name())
|
||||
|
||||
// Get metadata for the entry, including symlink info
|
||||
fileInfo, err := os.Lstat(entryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not stat entry %s: %v", entryPath, err)
|
||||
}
|
||||
|
||||
// Determine if the entry should be included
|
||||
if o.ItemIsDir {
|
||||
// Include directories or symlinks to directories
|
||||
if fileInfo.IsDir() || (fileInfo.Mode()&os.ModeSymlink != 0 && common.IsSymlinkToDir(entryPath)) {
|
||||
ret = append(ret, entry.Name())
|
||||
}
|
||||
return
|
||||
})
|
||||
} else {
|
||||
if o.FileExtension == "" {
|
||||
ret = lo.FilterMap(entries, func(item os.DirEntry, index int) (ret string, ok bool) {
|
||||
if ok = !item.IsDir(); ok {
|
||||
ret = item.Name()
|
||||
}
|
||||
return
|
||||
})
|
||||
} else {
|
||||
ret = lo.FilterMap(entries, func(item os.DirEntry, index int) (ret string, ok bool) {
|
||||
if ok = !item.IsDir() && filepath.Ext(item.Name()) == o.FileExtension; ok {
|
||||
ret = strings.TrimSuffix(item.Name(), o.FileExtension)
|
||||
// Include files, optionally filtering by extension
|
||||
if !fileInfo.IsDir() {
|
||||
if o.FileExtension == "" || filepath.Ext(entry.Name()) == o.FileExtension {
|
||||
ret = append(ret, strings.TrimSuffix(entry.Name(), o.FileExtension))
|
||||
}
|
||||
return
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (o *StorageEntity) Delete(name string) (err error) {
|
||||
|
||||
@@ -17,128 +17,128 @@ type DateTimePlugin struct{}
|
||||
// Period: startofweek, endofweek, startofmonth, endofmonth
|
||||
// Relative: rel:-1h, rel:-2d, rel:1w, rel:3m, rel:1y
|
||||
func (p *DateTimePlugin) Apply(operation string, value string) (string, error) {
|
||||
debugf("DateTime: operation=%q value=%q", operation, value)
|
||||
|
||||
now := time.Now()
|
||||
debugf("DateTime: reference time=%v", now)
|
||||
|
||||
switch operation {
|
||||
// Time operations
|
||||
case "now":
|
||||
result := now.Format(time.RFC3339)
|
||||
debugf("DateTime: now=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "time":
|
||||
result := now.Format("15:04:05")
|
||||
debugf("DateTime: time=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "unix":
|
||||
result := fmt.Sprintf("%d", now.Unix())
|
||||
debugf("DateTime: unix=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "startofhour":
|
||||
result := now.Truncate(time.Hour).Format(time.RFC3339)
|
||||
debugf("DateTime: startofhour=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "endofhour":
|
||||
result := now.Truncate(time.Hour).Add(time.Hour - time.Second).Format(time.RFC3339)
|
||||
debugf("DateTime: endofhour=%q", result)
|
||||
return result, nil
|
||||
|
||||
// Date operations
|
||||
case "today":
|
||||
result := now.Format("2006-01-02")
|
||||
debugf("DateTime: today=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "full":
|
||||
result := now.Format("Monday, January 2, 2006")
|
||||
debugf("DateTime: full=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "month":
|
||||
result := now.Format("January")
|
||||
debugf("DateTime: month=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "year":
|
||||
result := now.Format("2006")
|
||||
debugf("DateTime: year=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "startofweek":
|
||||
result := now.AddDate(0, 0, -int(now.Weekday())).Format("2006-01-02")
|
||||
debugf("DateTime: startofweek=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "endofweek":
|
||||
result := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
|
||||
debugf("DateTime: endofweek=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "startofmonth":
|
||||
result := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||
debugf("DateTime: startofmonth=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "endofmonth":
|
||||
result := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||
debugf("DateTime: endofmonth=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "rel":
|
||||
return p.handleRelative(now, value)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("datetime: unknown operation %q (see plugin documentation for supported operations)", operation)
|
||||
}
|
||||
debugf("DateTime: operation=%q value=%q", operation, value)
|
||||
|
||||
now := time.Now()
|
||||
debugf("DateTime: reference time=%v", now)
|
||||
|
||||
switch operation {
|
||||
// Time operations
|
||||
case "now":
|
||||
result := now.Format(time.RFC3339)
|
||||
debugf("DateTime: now=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "time":
|
||||
result := now.Format("15:04:05")
|
||||
debugf("DateTime: time=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "unix":
|
||||
result := fmt.Sprintf("%d", now.Unix())
|
||||
debugf("DateTime: unix=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "startofhour":
|
||||
result := now.Truncate(time.Hour).Format(time.RFC3339)
|
||||
debugf("DateTime: startofhour=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "endofhour":
|
||||
result := now.Truncate(time.Hour).Add(time.Hour - time.Second).Format(time.RFC3339)
|
||||
debugf("DateTime: endofhour=%q", result)
|
||||
return result, nil
|
||||
|
||||
// Date operations
|
||||
case "today":
|
||||
result := now.Format("2006-01-02")
|
||||
debugf("DateTime: today=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "full":
|
||||
result := now.Format("Monday, January 2, 2006")
|
||||
debugf("DateTime: full=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "month":
|
||||
result := now.Format("January")
|
||||
debugf("DateTime: month=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "year":
|
||||
result := now.Format("2006")
|
||||
debugf("DateTime: year=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "startofweek":
|
||||
result := now.AddDate(0, 0, -int(now.Weekday())).Format("2006-01-02")
|
||||
debugf("DateTime: startofweek=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "endofweek":
|
||||
result := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
|
||||
debugf("DateTime: endofweek=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "startofmonth":
|
||||
result := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||
debugf("DateTime: startofmonth=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "endofmonth":
|
||||
result := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||
debugf("DateTime: endofmonth=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "rel":
|
||||
return p.handleRelative(now, value)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("datetime: unknown operation %q (see plugin documentation for supported operations)", operation)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, error) {
|
||||
debugf("DateTime: handling relative time value=%q", value)
|
||||
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("datetime: relative time requires a value (e.g., -1h, -1d, -1w)")
|
||||
}
|
||||
|
||||
// Try standard duration first (hours, minutes)
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
result := now.Add(duration).Format(time.RFC3339)
|
||||
debugf("DateTime: relative duration=%q result=%q", duration, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Handle date units
|
||||
if len(value) < 2 {
|
||||
return "", fmt.Errorf("datetime: invalid relative format (use: -1h, 2d, -3w, 1m, -1y)")
|
||||
}
|
||||
|
||||
unit := value[len(value)-1:]
|
||||
numStr := value[:len(value)-1]
|
||||
|
||||
num, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("datetime: invalid number in relative time: %q", value)
|
||||
}
|
||||
|
||||
var result string
|
||||
switch unit {
|
||||
case "d":
|
||||
result = now.AddDate(0, 0, num).Format("2006-01-02")
|
||||
case "w":
|
||||
result = now.AddDate(0, 0, num*7).Format("2006-01-02")
|
||||
case "m":
|
||||
result = now.AddDate(0, num, 0).Format("2006-01-02")
|
||||
case "y":
|
||||
result = now.AddDate(num, 0, 0).Format("2006-01-02")
|
||||
default:
|
||||
return "", fmt.Errorf("datetime: invalid unit %q (use: h,m for time or d,w,m,y for date)", unit)
|
||||
}
|
||||
|
||||
debugf("DateTime: relative unit=%q num=%d result=%q", unit, num, result)
|
||||
return result, nil
|
||||
}
|
||||
debugf("DateTime: handling relative time value=%q", value)
|
||||
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("datetime: relative time requires a value (e.g., -1h, -1d, -1w)")
|
||||
}
|
||||
|
||||
// Try standard duration first (hours, minutes)
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
result := now.Add(duration).Format(time.RFC3339)
|
||||
debugf("DateTime: relative duration=%q result=%q", duration, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Handle date units
|
||||
if len(value) < 2 {
|
||||
return "", fmt.Errorf("datetime: invalid relative format (use: -1h, 2d, -3w, 1m, -1y)")
|
||||
}
|
||||
|
||||
unit := value[len(value)-1:]
|
||||
numStr := value[:len(value)-1]
|
||||
|
||||
num, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("datetime: invalid number in relative time: %q", value)
|
||||
}
|
||||
|
||||
var result string
|
||||
switch unit {
|
||||
case "d":
|
||||
result = now.AddDate(0, 0, num).Format("2006-01-02")
|
||||
case "w":
|
||||
result = now.AddDate(0, 0, num*7).Format("2006-01-02")
|
||||
case "m":
|
||||
result = now.AddDate(0, num, 0).Format("2006-01-02")
|
||||
case "y":
|
||||
result = now.AddDate(num, 0, 0).Format("2006-01-02")
|
||||
default:
|
||||
return "", fmt.Errorf("datetime: invalid unit %q (use: h,m for time or d,w,m,y for date)", unit)
|
||||
}
|
||||
|
||||
debugf("DateTime: relative unit=%q num=%d result=%q", unit, num, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -9,130 +9,130 @@ import (
|
||||
)
|
||||
|
||||
func TestDateTimePlugin(t *testing.T) {
|
||||
plugin := &DateTimePlugin{}
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
validate func(string) error
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "now returns RFC3339",
|
||||
operation: "now",
|
||||
validate: func(got string) error {
|
||||
if _, err := time.Parse(time.RFC3339, got); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time returns HH:MM:SS",
|
||||
operation: "time",
|
||||
validate: func(got string) error {
|
||||
if _, err := time.Parse("15:04:05", got); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unix returns timestamp",
|
||||
operation: "unix",
|
||||
validate: func(got string) error {
|
||||
if _, err := strconv.ParseInt(got, 10, 64); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "today returns YYYY-MM-DD",
|
||||
operation: "today",
|
||||
validate: func(got string) error {
|
||||
if _, err := time.Parse("2006-01-02", got); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full returns long date",
|
||||
operation: "full",
|
||||
validate: func(got string) error {
|
||||
if !strings.Contains(got, now.Month().String()) {
|
||||
return fmt.Errorf("full date missing month name")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative positive hours",
|
||||
operation: "rel",
|
||||
value: "2h",
|
||||
validate: func(got string) error {
|
||||
t, err := time.Parse(time.RFC3339, got)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected := now.Add(2 * time.Hour)
|
||||
if t.Hour() != expected.Hour() {
|
||||
return fmt.Errorf("expected hour %d, got %d", expected.Hour(), t.Hour())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative negative days",
|
||||
operation: "rel",
|
||||
value: "-2d",
|
||||
validate: func(got string) error {
|
||||
t, err := time.Parse("2006-01-02", got)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected := now.AddDate(0, 0, -2)
|
||||
if t.Day() != expected.Day() {
|
||||
return fmt.Errorf("expected day %d, got %d", expected.Day(), t.Day())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
// Error cases
|
||||
{
|
||||
name: "invalid operation",
|
||||
operation: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty relative value",
|
||||
operation: "rel",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid relative format",
|
||||
operation: "rel",
|
||||
value: "2x",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
plugin := &DateTimePlugin{}
|
||||
now := time.Now()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := plugin.Apply(tt.operation, tt.value)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("DateTimePlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err == nil && tt.validate != nil {
|
||||
if err := tt.validate(got); err != nil {
|
||||
t.Errorf("DateTimePlugin.Apply() validation failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
validate func(string) error
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "now returns RFC3339",
|
||||
operation: "now",
|
||||
validate: func(got string) error {
|
||||
if _, err := time.Parse(time.RFC3339, got); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time returns HH:MM:SS",
|
||||
operation: "time",
|
||||
validate: func(got string) error {
|
||||
if _, err := time.Parse("15:04:05", got); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unix returns timestamp",
|
||||
operation: "unix",
|
||||
validate: func(got string) error {
|
||||
if _, err := strconv.ParseInt(got, 10, 64); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "today returns YYYY-MM-DD",
|
||||
operation: "today",
|
||||
validate: func(got string) error {
|
||||
if _, err := time.Parse("2006-01-02", got); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full returns long date",
|
||||
operation: "full",
|
||||
validate: func(got string) error {
|
||||
if !strings.Contains(got, now.Month().String()) {
|
||||
return fmt.Errorf("full date missing month name")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative positive hours",
|
||||
operation: "rel",
|
||||
value: "2h",
|
||||
validate: func(got string) error {
|
||||
t, err := time.Parse(time.RFC3339, got)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected := now.Add(2 * time.Hour)
|
||||
if t.Hour() != expected.Hour() {
|
||||
return fmt.Errorf("expected hour %d, got %d", expected.Hour(), t.Hour())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative negative days",
|
||||
operation: "rel",
|
||||
value: "-2d",
|
||||
validate: func(got string) error {
|
||||
t, err := time.Parse("2006-01-02", got)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected := now.AddDate(0, 0, -2)
|
||||
if t.Day() != expected.Day() {
|
||||
return fmt.Errorf("expected day %d, got %d", expected.Day(), t.Day())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
// Error cases
|
||||
{
|
||||
name: "invalid operation",
|
||||
operation: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty relative value",
|
||||
operation: "rel",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid relative format",
|
||||
operation: "rel",
|
||||
value: "2x",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := plugin.Apply(tt.operation, tt.value)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("DateTimePlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err == nil && tt.validate != nil {
|
||||
if err := tt.validate(got); err != nil {
|
||||
t.Errorf("DateTimePlugin.Apply() validation failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxContentSize limits response size to 1MB to prevent memory issues
|
||||
MaxContentSize = 1024 * 1024
|
||||
|
||||
// UserAgent identifies the client in HTTP requests
|
||||
UserAgent = "Fabric-Fetch/1.0"
|
||||
// MaxContentSize limits response size to 1MB to prevent memory issues
|
||||
MaxContentSize = 1024 * 1024
|
||||
|
||||
// UserAgent identifies the client in HTTP requests
|
||||
UserAgent = "Fabric-Fetch/1.0"
|
||||
)
|
||||
|
||||
// FetchPlugin provides HTTP fetching capabilities with safety constraints:
|
||||
@@ -31,104 +31,104 @@ type FetchPlugin struct{}
|
||||
// Apply executes fetch operations:
|
||||
// - get:URL: Fetches content from URL, returns text content
|
||||
func (p *FetchPlugin) Apply(operation string, value string) (string, error) {
|
||||
debugf("Fetch: operation=%q value=%q", operation, value)
|
||||
|
||||
switch operation {
|
||||
case "get":
|
||||
return p.fetch(value)
|
||||
default:
|
||||
return "", fmt.Errorf("fetch: unknown operation %q (supported: get)", operation)
|
||||
}
|
||||
debugf("Fetch: operation=%q value=%q", operation, value)
|
||||
|
||||
switch operation {
|
||||
case "get":
|
||||
return p.fetch(value)
|
||||
default:
|
||||
return "", fmt.Errorf("fetch: unknown operation %q (supported: get)", operation)
|
||||
}
|
||||
}
|
||||
|
||||
// isTextContent checks if the content type is text-based
|
||||
func (p *FetchPlugin) isTextContent(contentType string) bool {
|
||||
debugf("Fetch: checking content type %q", contentType)
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
debugf("Fetch: error parsing media type: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
isText := strings.HasPrefix(mediaType, "text/") ||
|
||||
mediaType == "application/json" ||
|
||||
mediaType == "application/xml" ||
|
||||
mediaType == "application/yaml" ||
|
||||
mediaType == "application/x-yaml" ||
|
||||
strings.HasSuffix(mediaType, "+json") ||
|
||||
strings.HasSuffix(mediaType, "+xml") ||
|
||||
strings.HasSuffix(mediaType, "+yaml")
|
||||
|
||||
debugf("Fetch: content type %q is text: %v", mediaType, isText)
|
||||
return isText
|
||||
debugf("Fetch: checking content type %q", contentType)
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
debugf("Fetch: error parsing media type: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
isText := strings.HasPrefix(mediaType, "text/") ||
|
||||
mediaType == "application/json" ||
|
||||
mediaType == "application/xml" ||
|
||||
mediaType == "application/yaml" ||
|
||||
mediaType == "application/x-yaml" ||
|
||||
strings.HasSuffix(mediaType, "+json") ||
|
||||
strings.HasSuffix(mediaType, "+xml") ||
|
||||
strings.HasSuffix(mediaType, "+yaml")
|
||||
|
||||
debugf("Fetch: content type %q is text: %v", mediaType, isText)
|
||||
return isText
|
||||
}
|
||||
|
||||
// validateTextContent ensures content is valid UTF-8 without null bytes
|
||||
func (p *FetchPlugin) validateTextContent(content []byte) error {
|
||||
debugf("Fetch: validating content length=%d bytes", len(content))
|
||||
|
||||
if !utf8.Valid(content) {
|
||||
return fmt.Errorf("fetch: content is not valid UTF-8 text")
|
||||
}
|
||||
|
||||
if bytes.Contains(content, []byte{0}) {
|
||||
return fmt.Errorf("fetch: content contains null bytes")
|
||||
}
|
||||
|
||||
debugf("Fetch: content validation successful")
|
||||
return nil
|
||||
debugf("Fetch: validating content length=%d bytes", len(content))
|
||||
|
||||
if !utf8.Valid(content) {
|
||||
return fmt.Errorf("fetch: content is not valid UTF-8 text")
|
||||
}
|
||||
|
||||
if bytes.Contains(content, []byte{0}) {
|
||||
return fmt.Errorf("fetch: content contains null bytes")
|
||||
}
|
||||
|
||||
debugf("Fetch: content validation successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetch retrieves content from a URL with safety checks
|
||||
func (p *FetchPlugin) fetch(urlStr string) (string, error) {
|
||||
debugf("Fetch: requesting URL %q", urlStr)
|
||||
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch: error creating request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch: error fetching URL: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
debugf("Fetch: got response status=%q", resp.Status)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("fetch: HTTP error: %d - %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
debugf("Fetch: requesting URL %q", urlStr)
|
||||
|
||||
if contentLength := resp.ContentLength; contentLength > MaxContentSize {
|
||||
return "", fmt.Errorf("fetch: content too large: %d bytes (max %d bytes)",
|
||||
contentLength, MaxContentSize)
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
debugf("Fetch: content-type=%q", contentType)
|
||||
if !p.isTextContent(contentType) {
|
||||
return "", fmt.Errorf("fetch: unsupported content type %q - only text content allowed",
|
||||
contentType)
|
||||
}
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch: error creating request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
|
||||
debugf("Fetch: reading response body")
|
||||
limitReader := io.LimitReader(resp.Body, MaxContentSize+1)
|
||||
content, err := io.ReadAll(limitReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch: error reading response: %v", err)
|
||||
}
|
||||
|
||||
if len(content) > MaxContentSize {
|
||||
return "", fmt.Errorf("fetch: content too large: exceeds %d bytes", MaxContentSize)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch: error fetching URL: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := p.validateTextContent(content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
debugf("Fetch: got response status=%q", resp.Status)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("fetch: HTTP error: %d - %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
debugf("Fetch: operation completed successfully, read %d bytes", len(content))
|
||||
return string(content), nil
|
||||
}
|
||||
if contentLength := resp.ContentLength; contentLength > MaxContentSize {
|
||||
return "", fmt.Errorf("fetch: content too large: %d bytes (max %d bytes)",
|
||||
contentLength, MaxContentSize)
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
debugf("Fetch: content-type=%q", contentType)
|
||||
if !p.isTextContent(contentType) {
|
||||
return "", fmt.Errorf("fetch: unsupported content type %q - only text content allowed",
|
||||
contentType)
|
||||
}
|
||||
|
||||
debugf("Fetch: reading response body")
|
||||
limitReader := io.LimitReader(resp.Body, MaxContentSize+1)
|
||||
content, err := io.ReadAll(limitReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch: error reading response: %v", err)
|
||||
}
|
||||
|
||||
if len(content) > MaxContentSize {
|
||||
return "", fmt.Errorf("fetch: content too large: exceeds %d bytes", MaxContentSize)
|
||||
}
|
||||
|
||||
if err := p.validateTextContent(content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
debugf("Fetch: operation completed successfully, read %d bytes", len(content))
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
@@ -5,67 +5,68 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchPlugin(t *testing.T) {
|
||||
plugin := &FetchPlugin{}
|
||||
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
server func() *httptest.Server
|
||||
wantErr bool
|
||||
errContains string
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
server func() *httptest.Server
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
// ... keep existing valid test cases ...
|
||||
|
||||
{
|
||||
name: "invalid URL",
|
||||
operation: "get",
|
||||
value: "not-a-url",
|
||||
wantErr: true,
|
||||
errContains: "unsupported protocol", // Updated to match actual error
|
||||
},
|
||||
{
|
||||
name: "malformed URL",
|
||||
operation: "get",
|
||||
value: "http://[::1]:namedport",
|
||||
wantErr: true,
|
||||
errContains: "error creating request",
|
||||
},
|
||||
// ... keep other test cases ...
|
||||
// ... keep existing valid test cases ...
|
||||
|
||||
{
|
||||
name: "invalid URL",
|
||||
operation: "get",
|
||||
value: "not-a-url",
|
||||
wantErr: true,
|
||||
errContains: "unsupported protocol", // Updated to match actual error
|
||||
},
|
||||
{
|
||||
name: "malformed URL",
|
||||
operation: "get",
|
||||
value: "http://[::1]:namedport",
|
||||
wantErr: true,
|
||||
errContains: "error creating request",
|
||||
},
|
||||
// ... keep other test cases ...
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var url string
|
||||
if tt.server != nil {
|
||||
server := tt.server()
|
||||
defer server.Close()
|
||||
url = server.URL
|
||||
} else {
|
||||
url = tt.value
|
||||
}
|
||||
|
||||
got, err := plugin.Apply(tt.operation, url)
|
||||
|
||||
// Check error cases
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FetchPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil && tt.errContains != "" {
|
||||
if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
|
||||
t.Logf("Full error: %v", err) // Added for better debugging
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For successful cases, verify we got some content
|
||||
if err == nil && got == "" {
|
||||
t.Error("FetchPlugin.Apply() returned empty content on success")
|
||||
}
|
||||
})
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var url string
|
||||
if tt.server != nil {
|
||||
server := tt.server()
|
||||
defer server.Close()
|
||||
url = server.URL
|
||||
} else {
|
||||
url = tt.value
|
||||
}
|
||||
|
||||
got, err := plugin.Apply(tt.operation, url)
|
||||
|
||||
// Check error cases
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FetchPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil && tt.errContains != "" {
|
||||
if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
|
||||
t.Logf("Full error: %v", err) // Added for better debugging
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For successful cases, verify we got some content
|
||||
if err == nil && got == "" {
|
||||
t.Error("FetchPlugin.Apply() returned empty content on success")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,26 +24,26 @@ type FilePlugin struct{}
|
||||
|
||||
// safePath validates and normalizes file paths
|
||||
func (p *FilePlugin) safePath(path string) (string, error) {
|
||||
debugf("File: validating path %q", path)
|
||||
|
||||
// Basic security check - no path traversal
|
||||
if strings.Contains(path, "..") {
|
||||
return "", fmt.Errorf("file: path cannot contain '..'")
|
||||
}
|
||||
|
||||
// Expand home directory if needed
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not expand home directory: %v", err)
|
||||
}
|
||||
path = filepath.Join(home, path[2:])
|
||||
}
|
||||
|
||||
// Clean the path
|
||||
cleaned := filepath.Clean(path)
|
||||
debugf("File: cleaned path %q", cleaned)
|
||||
return cleaned, nil
|
||||
debugf("File: validating path %q", path)
|
||||
|
||||
// Basic security check - no path traversal
|
||||
if strings.Contains(path, "..") {
|
||||
return "", fmt.Errorf("file: path cannot contain '..'")
|
||||
}
|
||||
|
||||
// Expand home directory if needed
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not expand home directory: %v", err)
|
||||
}
|
||||
path = filepath.Join(home, path[2:])
|
||||
}
|
||||
|
||||
// Clean the path
|
||||
cleaned := filepath.Clean(path)
|
||||
debugf("File: cleaned path %q", cleaned)
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// Apply executes file operations:
|
||||
@@ -53,145 +53,145 @@ func (p *FilePlugin) safePath(path string) (string, error) {
|
||||
// - size:PATH - Get file size in bytes
|
||||
// - modified:PATH - Get last modified time
|
||||
func (p *FilePlugin) Apply(operation string, value string) (string, error) {
|
||||
debugf("File: operation=%q value=%q", operation, value)
|
||||
|
||||
switch operation {
|
||||
case "tail":
|
||||
parts := strings.Split(value, "|")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("file: tail requires format path|lines")
|
||||
}
|
||||
|
||||
path, err := p.safePath(parts[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: invalid line count %q", parts[1])
|
||||
}
|
||||
|
||||
if n < 1 {
|
||||
return "", fmt.Errorf("file: line count must be positive")
|
||||
}
|
||||
|
||||
lines, err := p.lastNLines(path, n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := strings.Join(lines, "\n")
|
||||
debugf("File: tail returning %d lines", len(lines))
|
||||
return result, nil
|
||||
debugf("File: operation=%q value=%q", operation, value)
|
||||
|
||||
case "read":
|
||||
path, err := p.safePath(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not stat file: %v", err)
|
||||
}
|
||||
|
||||
if info.Size() > MaxFileSize {
|
||||
return "", fmt.Errorf("file: size %d exceeds limit of %d bytes",
|
||||
info.Size(), MaxFileSize)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not read: %v", err)
|
||||
}
|
||||
|
||||
debugf("File: read %d bytes", len(content))
|
||||
return string(content), nil
|
||||
switch operation {
|
||||
case "tail":
|
||||
parts := strings.Split(value, "|")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("file: tail requires format path|lines")
|
||||
}
|
||||
|
||||
case "exists":
|
||||
path, err := p.safePath(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = os.Stat(path)
|
||||
exists := err == nil
|
||||
debugf("File: exists=%v for path %q", exists, path)
|
||||
return fmt.Sprintf("%t", exists), nil
|
||||
path, err := p.safePath(parts[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
case "size":
|
||||
path, err := p.safePath(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not stat file: %v", err)
|
||||
}
|
||||
|
||||
size := info.Size()
|
||||
debugf("File: size=%d for path %q", size, path)
|
||||
return fmt.Sprintf("%d", size), nil
|
||||
n, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: invalid line count %q", parts[1])
|
||||
}
|
||||
|
||||
case "modified":
|
||||
path, err := p.safePath(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not stat file: %v", err)
|
||||
}
|
||||
|
||||
mtime := info.ModTime().Format(time.RFC3339)
|
||||
debugf("File: modified=%q for path %q", mtime, path)
|
||||
return mtime, nil
|
||||
if n < 1 {
|
||||
return "", fmt.Errorf("file: line count must be positive")
|
||||
}
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("file: unknown operation %q (supported: read, tail, exists, size, modified)",
|
||||
operation)
|
||||
}
|
||||
lines, err := p.lastNLines(path, n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := strings.Join(lines, "\n")
|
||||
debugf("File: tail returning %d lines", len(lines))
|
||||
return result, nil
|
||||
|
||||
case "read":
|
||||
path, err := p.safePath(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not stat file: %v", err)
|
||||
}
|
||||
|
||||
if info.Size() > MaxFileSize {
|
||||
return "", fmt.Errorf("file: size %d exceeds limit of %d bytes",
|
||||
info.Size(), MaxFileSize)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not read: %v", err)
|
||||
}
|
||||
|
||||
debugf("File: read %d bytes", len(content))
|
||||
return string(content), nil
|
||||
|
||||
case "exists":
|
||||
path, err := p.safePath(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = os.Stat(path)
|
||||
exists := err == nil
|
||||
debugf("File: exists=%v for path %q", exists, path)
|
||||
return fmt.Sprintf("%t", exists), nil
|
||||
|
||||
case "size":
|
||||
path, err := p.safePath(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not stat file: %v", err)
|
||||
}
|
||||
|
||||
size := info.Size()
|
||||
debugf("File: size=%d for path %q", size, path)
|
||||
return fmt.Sprintf("%d", size), nil
|
||||
|
||||
case "modified":
|
||||
path, err := p.safePath(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file: could not stat file: %v", err)
|
||||
}
|
||||
|
||||
mtime := info.ModTime().Format(time.RFC3339)
|
||||
debugf("File: modified=%q for path %q", mtime, path)
|
||||
return mtime, nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("file: unknown operation %q (supported: read, tail, exists, size, modified)",
|
||||
operation)
|
||||
}
|
||||
}
|
||||
|
||||
// lastNLines returns the last n lines from a file
|
||||
func (p *FilePlugin) lastNLines(path string, n int) ([]string, error) {
|
||||
debugf("File: reading last %d lines from %q", n, path)
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file: could not open: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
debugf("File: reading last %d lines from %q", n, path)
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file: could not stat: %v", err)
|
||||
}
|
||||
|
||||
if info.Size() > MaxFileSize {
|
||||
return nil, fmt.Errorf("file: size %d exceeds limit of %d bytes",
|
||||
info.Size(), MaxFileSize)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file: could not open: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
lines := make([]string, 0, n)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
lineCount := 0
|
||||
for scanner.Scan() {
|
||||
lineCount++
|
||||
if len(lines) == n {
|
||||
lines = lines[1:]
|
||||
}
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("file: error reading: %v", err)
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file: could not stat: %v", err)
|
||||
}
|
||||
|
||||
debugf("File: read %d lines total, returning last %d", lineCount, len(lines))
|
||||
return lines, nil
|
||||
if info.Size() > MaxFileSize {
|
||||
return nil, fmt.Errorf("file: size %d exceeds limit of %d bytes",
|
||||
info.Size(), MaxFileSize)
|
||||
}
|
||||
|
||||
lines := make([]string, 0, n)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
lineCount := 0
|
||||
for scanner.Scan() {
|
||||
lineCount++
|
||||
if len(lines) == n {
|
||||
lines = lines[1:]
|
||||
}
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("file: error reading: %v", err)
|
||||
}
|
||||
|
||||
debugf("File: read %d lines total, returning last %d", lineCount, len(lines))
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
@@ -9,144 +9,144 @@ import (
|
||||
)
|
||||
|
||||
func TestFilePlugin(t *testing.T) {
|
||||
plugin := &FilePlugin{}
|
||||
|
||||
// Create temp test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
content := "line1\nline2\nline3\nline4\nline5\n"
|
||||
err := os.WriteFile(testFile, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bigFile := filepath.Join(tmpDir, "big.txt")
|
||||
err = os.WriteFile(bigFile, []byte(strings.Repeat("x", MaxFileSize+1)), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin := &FilePlugin{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
want string
|
||||
wantErr bool
|
||||
errContains string
|
||||
validate func(string) bool
|
||||
}{
|
||||
{
|
||||
name: "read file",
|
||||
operation: "read",
|
||||
value: testFile,
|
||||
want: content,
|
||||
},
|
||||
{
|
||||
name: "tail file",
|
||||
operation: "tail",
|
||||
value: testFile + "|3",
|
||||
want: "line3\nline4\nline5",
|
||||
},
|
||||
{
|
||||
name: "exists true",
|
||||
operation: "exists",
|
||||
value: testFile,
|
||||
want: "true",
|
||||
},
|
||||
{
|
||||
name: "exists false",
|
||||
operation: "exists",
|
||||
value: filepath.Join(tmpDir, "nonexistent.txt"),
|
||||
want: "false",
|
||||
},
|
||||
{
|
||||
name: "size",
|
||||
operation: "size",
|
||||
value: testFile,
|
||||
want: "30",
|
||||
},
|
||||
{
|
||||
name: "modified",
|
||||
operation: "modified",
|
||||
value: testFile,
|
||||
validate: func(got string) bool {
|
||||
_, err := time.Parse(time.RFC3339, got)
|
||||
return err == nil
|
||||
},
|
||||
},
|
||||
// Error cases
|
||||
{
|
||||
name: "read non-existent",
|
||||
operation: "read",
|
||||
value: filepath.Join(tmpDir, "nonexistent.txt"),
|
||||
wantErr: true,
|
||||
errContains: "could not stat file",
|
||||
},
|
||||
{
|
||||
name: "invalid operation",
|
||||
operation: "invalid",
|
||||
value: testFile,
|
||||
wantErr: true,
|
||||
errContains: "unknown operation",
|
||||
},
|
||||
{
|
||||
name: "path traversal attempt",
|
||||
operation: "read",
|
||||
value: "../../../etc/passwd",
|
||||
wantErr: true,
|
||||
errContains: "cannot contain '..'",
|
||||
},
|
||||
{
|
||||
name: "file too large",
|
||||
operation: "read",
|
||||
value: bigFile,
|
||||
wantErr: true,
|
||||
errContains: "exceeds limit",
|
||||
},
|
||||
{
|
||||
name: "invalid tail format",
|
||||
operation: "tail",
|
||||
value: testFile,
|
||||
wantErr: true,
|
||||
errContains: "requires format path|lines",
|
||||
},
|
||||
{
|
||||
name: "invalid tail count",
|
||||
operation: "tail",
|
||||
value: testFile + "|invalid",
|
||||
wantErr: true,
|
||||
errContains: "invalid line count",
|
||||
},
|
||||
}
|
||||
// Create temp test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := plugin.Apply(tt.operation, tt.value)
|
||||
|
||||
// Check error cases
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FilePlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil && tt.errContains != "" {
|
||||
if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check success cases
|
||||
if err == nil {
|
||||
if tt.validate != nil {
|
||||
if !tt.validate(got) {
|
||||
t.Errorf("FilePlugin.Apply() returned invalid result: %q", got)
|
||||
}
|
||||
} else if tt.want != "" && got != tt.want {
|
||||
t.Errorf("FilePlugin.Apply() = %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
content := "line1\nline2\nline3\nline4\nline5\n"
|
||||
err := os.WriteFile(testFile, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bigFile := filepath.Join(tmpDir, "big.txt")
|
||||
err = os.WriteFile(bigFile, []byte(strings.Repeat("x", MaxFileSize+1)), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
want string
|
||||
wantErr bool
|
||||
errContains string
|
||||
validate func(string) bool
|
||||
}{
|
||||
{
|
||||
name: "read file",
|
||||
operation: "read",
|
||||
value: testFile,
|
||||
want: content,
|
||||
},
|
||||
{
|
||||
name: "tail file",
|
||||
operation: "tail",
|
||||
value: testFile + "|3",
|
||||
want: "line3\nline4\nline5",
|
||||
},
|
||||
{
|
||||
name: "exists true",
|
||||
operation: "exists",
|
||||
value: testFile,
|
||||
want: "true",
|
||||
},
|
||||
{
|
||||
name: "exists false",
|
||||
operation: "exists",
|
||||
value: filepath.Join(tmpDir, "nonexistent.txt"),
|
||||
want: "false",
|
||||
},
|
||||
{
|
||||
name: "size",
|
||||
operation: "size",
|
||||
value: testFile,
|
||||
want: "30",
|
||||
},
|
||||
{
|
||||
name: "modified",
|
||||
operation: "modified",
|
||||
value: testFile,
|
||||
validate: func(got string) bool {
|
||||
_, err := time.Parse(time.RFC3339, got)
|
||||
return err == nil
|
||||
},
|
||||
},
|
||||
// Error cases
|
||||
{
|
||||
name: "read non-existent",
|
||||
operation: "read",
|
||||
value: filepath.Join(tmpDir, "nonexistent.txt"),
|
||||
wantErr: true,
|
||||
errContains: "could not stat file",
|
||||
},
|
||||
{
|
||||
name: "invalid operation",
|
||||
operation: "invalid",
|
||||
value: testFile,
|
||||
wantErr: true,
|
||||
errContains: "unknown operation",
|
||||
},
|
||||
{
|
||||
name: "path traversal attempt",
|
||||
operation: "read",
|
||||
value: "../../../etc/passwd",
|
||||
wantErr: true,
|
||||
errContains: "cannot contain '..'",
|
||||
},
|
||||
{
|
||||
name: "file too large",
|
||||
operation: "read",
|
||||
value: bigFile,
|
||||
wantErr: true,
|
||||
errContains: "exceeds limit",
|
||||
},
|
||||
{
|
||||
name: "invalid tail format",
|
||||
operation: "tail",
|
||||
value: testFile,
|
||||
wantErr: true,
|
||||
errContains: "requires format path|lines",
|
||||
},
|
||||
{
|
||||
name: "invalid tail count",
|
||||
operation: "tail",
|
||||
value: testFile + "|invalid",
|
||||
wantErr: true,
|
||||
errContains: "invalid line count",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := plugin.Apply(tt.operation, tt.value)
|
||||
|
||||
// Check error cases
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("FilePlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil && tt.errContains != "" {
|
||||
if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check success cases
|
||||
if err == nil {
|
||||
if tt.validate != nil {
|
||||
if !tt.validate(got) {
|
||||
t.Errorf("FilePlugin.Apply() returned invalid result: %q", got)
|
||||
}
|
||||
} else if tt.want != "" && got != tt.want {
|
||||
t.Errorf("FilePlugin.Apply() = %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,66 +22,66 @@ type SysPlugin struct{}
|
||||
// - pwd: Current working directory
|
||||
// - home: User's home directory
|
||||
func (p *SysPlugin) Apply(operation string, value string) (string, error) {
|
||||
debugf("Sys: operation=%q value=%q", operation, value)
|
||||
|
||||
switch operation {
|
||||
case "hostname":
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
debugf("Sys: hostname error: %v", err)
|
||||
return "", fmt.Errorf("sys: hostname error: %v", err)
|
||||
}
|
||||
debugf("Sys: hostname=%q", hostname)
|
||||
return hostname, nil
|
||||
|
||||
case "user":
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
debugf("Sys: user error: %v", err)
|
||||
return "", fmt.Errorf("sys: user error: %v", err)
|
||||
}
|
||||
debugf("Sys: user=%q", currentUser.Username)
|
||||
return currentUser.Username, nil
|
||||
|
||||
case "os":
|
||||
result := runtime.GOOS
|
||||
debugf("Sys: os=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "arch":
|
||||
result := runtime.GOARCH
|
||||
debugf("Sys: arch=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "env":
|
||||
if value == "" {
|
||||
debugf("Sys: env error: missing variable name")
|
||||
return "", fmt.Errorf("sys: env operation requires a variable name")
|
||||
}
|
||||
result := os.Getenv(value)
|
||||
debugf("Sys: env %q=%q", value, result)
|
||||
return result, nil
|
||||
|
||||
case "pwd":
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
debugf("Sys: pwd error: %v", err)
|
||||
return "", fmt.Errorf("sys: pwd error: %v", err)
|
||||
}
|
||||
debugf("Sys: pwd=%q", dir)
|
||||
return dir, nil
|
||||
|
||||
case "home":
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
debugf("Sys: home error: %v", err)
|
||||
return "", fmt.Errorf("sys: home error: %v", err)
|
||||
}
|
||||
debugf("Sys: home=%q", homeDir)
|
||||
return homeDir, nil
|
||||
debugf("Sys: operation=%q value=%q", operation, value)
|
||||
|
||||
default:
|
||||
debugf("Sys: unknown operation %q", operation)
|
||||
return "", fmt.Errorf("sys: unknown operation %q (supported: hostname, user, os, arch, env, pwd, home)", operation)
|
||||
}
|
||||
}
|
||||
switch operation {
|
||||
case "hostname":
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
debugf("Sys: hostname error: %v", err)
|
||||
return "", fmt.Errorf("sys: hostname error: %v", err)
|
||||
}
|
||||
debugf("Sys: hostname=%q", hostname)
|
||||
return hostname, nil
|
||||
|
||||
case "user":
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
debugf("Sys: user error: %v", err)
|
||||
return "", fmt.Errorf("sys: user error: %v", err)
|
||||
}
|
||||
debugf("Sys: user=%q", currentUser.Username)
|
||||
return currentUser.Username, nil
|
||||
|
||||
case "os":
|
||||
result := runtime.GOOS
|
||||
debugf("Sys: os=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "arch":
|
||||
result := runtime.GOARCH
|
||||
debugf("Sys: arch=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "env":
|
||||
if value == "" {
|
||||
debugf("Sys: env error: missing variable name")
|
||||
return "", fmt.Errorf("sys: env operation requires a variable name")
|
||||
}
|
||||
result := os.Getenv(value)
|
||||
debugf("Sys: env %q=%q", value, result)
|
||||
return result, nil
|
||||
|
||||
case "pwd":
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
debugf("Sys: pwd error: %v", err)
|
||||
return "", fmt.Errorf("sys: pwd error: %v", err)
|
||||
}
|
||||
debugf("Sys: pwd=%q", dir)
|
||||
return dir, nil
|
||||
|
||||
case "home":
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
debugf("Sys: home error: %v", err)
|
||||
return "", fmt.Errorf("sys: home error: %v", err)
|
||||
}
|
||||
debugf("Sys: home=%q", homeDir)
|
||||
return homeDir, nil
|
||||
|
||||
default:
|
||||
debugf("Sys: unknown operation %q", operation)
|
||||
return "", fmt.Errorf("sys: unknown operation %q (supported: hostname, user, os, arch, env, pwd, home)", operation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,131 +10,131 @@ import (
|
||||
)
|
||||
|
||||
func TestSysPlugin(t *testing.T) {
|
||||
plugin := &SysPlugin{}
|
||||
|
||||
// Set up test environment variable
|
||||
const testEnvVar = "FABRIC_TEST_VAR"
|
||||
const testEnvValue = "test_value"
|
||||
os.Setenv(testEnvVar, testEnvValue)
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
validate func(string) error
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "hostname returns valid name",
|
||||
operation: "hostname",
|
||||
validate: func(got string) error {
|
||||
if got == "" {
|
||||
return fmt.Errorf("hostname is empty")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user returns current user",
|
||||
operation: "user",
|
||||
validate: func(got string) error {
|
||||
if got == "" {
|
||||
return fmt.Errorf("username is empty")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "os returns valid OS",
|
||||
operation: "os",
|
||||
validate: func(got string) error {
|
||||
if got != runtime.GOOS {
|
||||
return fmt.Errorf("expected OS %s, got %s", runtime.GOOS, got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "arch returns valid architecture",
|
||||
operation: "arch",
|
||||
validate: func(got string) error {
|
||||
if got != runtime.GOARCH {
|
||||
return fmt.Errorf("expected arch %s, got %s", runtime.GOARCH, got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env returns environment variable",
|
||||
operation: "env",
|
||||
value: testEnvVar,
|
||||
validate: func(got string) error {
|
||||
if got != testEnvValue {
|
||||
return fmt.Errorf("expected env var %s, got %s", testEnvValue, got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pwd returns valid directory",
|
||||
operation: "pwd",
|
||||
validate: func(got string) error {
|
||||
if !filepath.IsAbs(got) {
|
||||
return fmt.Errorf("expected absolute path, got %s", got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "home returns valid home directory",
|
||||
operation: "home",
|
||||
validate: func(got string) error {
|
||||
if !filepath.IsAbs(got) {
|
||||
return fmt.Errorf("expected absolute path, got %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "home") && !strings.Contains(got, "Users") {
|
||||
return fmt.Errorf("path %s doesn't look like a home directory", got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
// Error cases
|
||||
{
|
||||
name: "unknown operation",
|
||||
operation: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "env without variable",
|
||||
operation: "env",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "env with non-existent variable",
|
||||
operation: "env",
|
||||
value: "NONEXISTENT_VAR_123456",
|
||||
validate: func(got string) error {
|
||||
if got != "" {
|
||||
return fmt.Errorf("expected empty string for non-existent env var, got %s", got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := &SysPlugin{}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := plugin.Apply(tt.operation, tt.value)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SysPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err == nil && tt.validate != nil {
|
||||
if err := tt.validate(got); err != nil {
|
||||
t.Errorf("SysPlugin.Apply() validation failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// Set up test environment variable
|
||||
const testEnvVar = "FABRIC_TEST_VAR"
|
||||
const testEnvValue = "test_value"
|
||||
os.Setenv(testEnvVar, testEnvValue)
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
validate func(string) error
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "hostname returns valid name",
|
||||
operation: "hostname",
|
||||
validate: func(got string) error {
|
||||
if got == "" {
|
||||
return fmt.Errorf("hostname is empty")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user returns current user",
|
||||
operation: "user",
|
||||
validate: func(got string) error {
|
||||
if got == "" {
|
||||
return fmt.Errorf("username is empty")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "os returns valid OS",
|
||||
operation: "os",
|
||||
validate: func(got string) error {
|
||||
if got != runtime.GOOS {
|
||||
return fmt.Errorf("expected OS %s, got %s", runtime.GOOS, got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "arch returns valid architecture",
|
||||
operation: "arch",
|
||||
validate: func(got string) error {
|
||||
if got != runtime.GOARCH {
|
||||
return fmt.Errorf("expected arch %s, got %s", runtime.GOARCH, got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env returns environment variable",
|
||||
operation: "env",
|
||||
value: testEnvVar,
|
||||
validate: func(got string) error {
|
||||
if got != testEnvValue {
|
||||
return fmt.Errorf("expected env var %s, got %s", testEnvValue, got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pwd returns valid directory",
|
||||
operation: "pwd",
|
||||
validate: func(got string) error {
|
||||
if !filepath.IsAbs(got) {
|
||||
return fmt.Errorf("expected absolute path, got %s", got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "home returns valid home directory",
|
||||
operation: "home",
|
||||
validate: func(got string) error {
|
||||
if !filepath.IsAbs(got) {
|
||||
return fmt.Errorf("expected absolute path, got %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "home") && !strings.Contains(got, "Users") {
|
||||
return fmt.Errorf("path %s doesn't look like a home directory", got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
// Error cases
|
||||
{
|
||||
name: "unknown operation",
|
||||
operation: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "env without variable",
|
||||
operation: "env",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "env with non-existent variable",
|
||||
operation: "env",
|
||||
value: "NONEXISTENT_VAR_123456",
|
||||
validate: func(got string) error {
|
||||
if got != "" {
|
||||
return fmt.Errorf("expected empty string for non-existent env var, got %s", got)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := plugin.Apply(tt.operation, tt.value)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SysPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err == nil && tt.validate != nil {
|
||||
if err := tt.validate(got); err != nil {
|
||||
t.Errorf("SysPlugin.Apply() validation failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,113 +7,113 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
textPlugin = &TextPlugin{}
|
||||
datetimePlugin = &DateTimePlugin{}
|
||||
filePlugin = &FilePlugin{}
|
||||
fetchPlugin = &FetchPlugin{}
|
||||
sysPlugin = &SysPlugin{}
|
||||
Debug = false // Debug flag
|
||||
textPlugin = &TextPlugin{}
|
||||
datetimePlugin = &DateTimePlugin{}
|
||||
filePlugin = &FilePlugin{}
|
||||
fetchPlugin = &FetchPlugin{}
|
||||
sysPlugin = &SysPlugin{}
|
||||
Debug = false // Debug flag
|
||||
)
|
||||
|
||||
var pluginPattern = regexp.MustCompile(`\{\{plugin:([^:]+):([^:]+)(?::([^}]+))?\}\}`)
|
||||
|
||||
func debugf(format string, a ...interface{}) {
|
||||
if Debug {
|
||||
fmt.Printf(format, a...)
|
||||
}
|
||||
if Debug {
|
||||
fmt.Printf(format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyTemplate(content string, variables map[string]string, input string) (string, error) {
|
||||
var missingVars []string
|
||||
r := regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
||||
|
||||
debugf("Starting template processing\n")
|
||||
for strings.Contains(content, "{{") {
|
||||
matches := r.FindAllStringSubmatch(content, -1)
|
||||
if len(matches) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for _, match := range matches {
|
||||
fullMatch := match[0]
|
||||
varName := match[1]
|
||||
|
||||
// Check if this is a plugin call
|
||||
if strings.HasPrefix(varName, "plugin:") {
|
||||
pluginMatches := pluginPattern.FindStringSubmatch(fullMatch)
|
||||
if len(pluginMatches) >= 3 {
|
||||
namespace := pluginMatches[1]
|
||||
operation := pluginMatches[2]
|
||||
value := ""
|
||||
if len(pluginMatches) == 4 {
|
||||
value = pluginMatches[3]
|
||||
}
|
||||
|
||||
debugf("\nPlugin call:\n")
|
||||
debugf(" Namespace: %s\n", namespace)
|
||||
debugf(" Operation: %s\n", operation)
|
||||
debugf(" Value: %s\n", value)
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
switch namespace {
|
||||
case "text":
|
||||
debugf("Executing text plugin\n")
|
||||
result, err = textPlugin.Apply(operation, value)
|
||||
case "datetime":
|
||||
debugf("Executing datetime plugin\n")
|
||||
result, err = datetimePlugin.Apply(operation, value)
|
||||
case "file":
|
||||
debugf("Executing file plugin\n")
|
||||
result, err = filePlugin.Apply(operation, value)
|
||||
debugf("File plugin result: %#v\n", result)
|
||||
case "fetch":
|
||||
debugf("Executing fetch plugin\n")
|
||||
result, err = fetchPlugin.Apply(operation, value)
|
||||
case "sys":
|
||||
debugf("Executing sys plugin\n")
|
||||
result, err = sysPlugin.Apply(operation, value)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown plugin namespace: %s", namespace)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
debugf("Plugin error: %v\n", err)
|
||||
return "", fmt.Errorf("plugin %s error: %v", namespace, err)
|
||||
}
|
||||
|
||||
debugf("Plugin result: %s\n", result)
|
||||
content = strings.ReplaceAll(content, fullMatch, result)
|
||||
debugf("Content after replacement: %s\n", content)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular variables and input
|
||||
debugf("Processing variable: %s\n", varName)
|
||||
if varName == "input" {
|
||||
debugf("Replacing {{input}}\n")
|
||||
replaced = true
|
||||
content = strings.ReplaceAll(content, fullMatch, input)
|
||||
} else {
|
||||
if val, ok := variables[varName]; !ok {
|
||||
debugf("Missing variable: %s\n", varName)
|
||||
missingVars = append(missingVars, varName)
|
||||
return "", fmt.Errorf("missing required variable: %s", varName)
|
||||
} else {
|
||||
debugf("Replacing variable %s with value: %s\n", varName, val)
|
||||
content = strings.ReplaceAll(content, fullMatch, val)
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
return "", fmt.Errorf("template processing stuck - potential infinite loop")
|
||||
}
|
||||
}
|
||||
}
|
||||
var missingVars []string
|
||||
r := regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
||||
|
||||
debugf("Template processing complete\n")
|
||||
return content, nil
|
||||
}
|
||||
debugf("Starting template processing\n")
|
||||
for strings.Contains(content, "{{") {
|
||||
matches := r.FindAllStringSubmatch(content, -1)
|
||||
if len(matches) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for _, match := range matches {
|
||||
fullMatch := match[0]
|
||||
varName := match[1]
|
||||
|
||||
// Check if this is a plugin call
|
||||
if strings.HasPrefix(varName, "plugin:") {
|
||||
pluginMatches := pluginPattern.FindStringSubmatch(fullMatch)
|
||||
if len(pluginMatches) >= 3 {
|
||||
namespace := pluginMatches[1]
|
||||
operation := pluginMatches[2]
|
||||
value := ""
|
||||
if len(pluginMatches) == 4 {
|
||||
value = pluginMatches[3]
|
||||
}
|
||||
|
||||
debugf("\nPlugin call:\n")
|
||||
debugf(" Namespace: %s\n", namespace)
|
||||
debugf(" Operation: %s\n", operation)
|
||||
debugf(" Value: %s\n", value)
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
switch namespace {
|
||||
case "text":
|
||||
debugf("Executing text plugin\n")
|
||||
result, err = textPlugin.Apply(operation, value)
|
||||
case "datetime":
|
||||
debugf("Executing datetime plugin\n")
|
||||
result, err = datetimePlugin.Apply(operation, value)
|
||||
case "file":
|
||||
debugf("Executing file plugin\n")
|
||||
result, err = filePlugin.Apply(operation, value)
|
||||
debugf("File plugin result: %#v\n", result)
|
||||
case "fetch":
|
||||
debugf("Executing fetch plugin\n")
|
||||
result, err = fetchPlugin.Apply(operation, value)
|
||||
case "sys":
|
||||
debugf("Executing sys plugin\n")
|
||||
result, err = sysPlugin.Apply(operation, value)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown plugin namespace: %s", namespace)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
debugf("Plugin error: %v\n", err)
|
||||
return "", fmt.Errorf("plugin %s error: %v", namespace, err)
|
||||
}
|
||||
|
||||
debugf("Plugin result: %s\n", result)
|
||||
content = strings.ReplaceAll(content, fullMatch, result)
|
||||
debugf("Content after replacement: %s\n", content)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular variables and input
|
||||
debugf("Processing variable: %s\n", varName)
|
||||
if varName == "input" {
|
||||
debugf("Replacing {{input}}\n")
|
||||
replaced = true
|
||||
content = strings.ReplaceAll(content, fullMatch, input)
|
||||
} else {
|
||||
if val, ok := variables[varName]; !ok {
|
||||
debugf("Missing variable: %s\n", varName)
|
||||
missingVars = append(missingVars, varName)
|
||||
return "", fmt.Errorf("missing required variable: %s", varName)
|
||||
} else {
|
||||
debugf("Replacing variable %s with value: %s\n", varName, val)
|
||||
content = strings.ReplaceAll(content, fullMatch, val)
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
return "", fmt.Errorf("template processing stuck - potential infinite loop")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugf("Template processing complete\n")
|
||||
return content, nil
|
||||
}
|
||||
|
||||
@@ -6,141 +6,140 @@ import (
|
||||
)
|
||||
|
||||
func TestApplyTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
vars map[string]string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
// Basic variable substitution
|
||||
{
|
||||
name: "simple variable",
|
||||
template: "Hello {{name}}!",
|
||||
vars: map[string]string{"name": "World"},
|
||||
want: "Hello World!",
|
||||
},
|
||||
{
|
||||
name: "multiple variables",
|
||||
template: "{{greeting}} {{name}}!",
|
||||
vars: map[string]string{
|
||||
"greeting": "Hello",
|
||||
"name": "World",
|
||||
},
|
||||
want: "Hello World!",
|
||||
},
|
||||
{
|
||||
name: "special input variable",
|
||||
template: "Content: {{input}}",
|
||||
input: "test content",
|
||||
want: "Content: test content",
|
||||
},
|
||||
|
||||
// Nested variable substitution
|
||||
{
|
||||
name: "nested variables",
|
||||
template: "{{outer{{inner}}}}",
|
||||
vars: map[string]string{
|
||||
"inner": "foo", // First resolution
|
||||
"outerfoo": "result", // Second resolution
|
||||
},
|
||||
want: "result",
|
||||
},
|
||||
|
||||
// Plugin operations
|
||||
{
|
||||
name: "simple text plugin",
|
||||
template: "{{plugin:text:upper:hello}}",
|
||||
want: "HELLO",
|
||||
},
|
||||
{
|
||||
name: "text plugin with variable",
|
||||
template: "{{plugin:text:upper:{{name}}}}",
|
||||
vars: map[string]string{"name": "world"},
|
||||
want: "WORLD",
|
||||
},
|
||||
{
|
||||
name: "plugin with dynamic operation",
|
||||
template: "{{plugin:text:{{operation}}:hello}}",
|
||||
vars: map[string]string{"operation": "upper"},
|
||||
want: "HELLO",
|
||||
},
|
||||
|
||||
// Multiple operations
|
||||
{
|
||||
name: "multiple plugins",
|
||||
template: "A:{{plugin:text:upper:hello}} B:{{plugin:text:lower:WORLD}}",
|
||||
want: "A:HELLO B:world",
|
||||
},
|
||||
{
|
||||
name: "nested plugins",
|
||||
template: "{{plugin:text:upper:{{plugin:text:lower:HELLO}}}}",
|
||||
want: "HELLO",
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "missing variable",
|
||||
template: "Hello {{name}}!",
|
||||
wantErr: true,
|
||||
errContains: "missing required variable",
|
||||
},
|
||||
{
|
||||
name: "unknown plugin",
|
||||
template: "{{plugin:invalid:op:value}}",
|
||||
wantErr: true,
|
||||
errContains: "unknown plugin namespace",
|
||||
},
|
||||
{
|
||||
name: "unknown plugin operation",
|
||||
template: "{{plugin:text:invalid:value}}",
|
||||
wantErr: true,
|
||||
errContains: "unknown text operation",
|
||||
},
|
||||
{
|
||||
name: "nested plugin error",
|
||||
template: "{{plugin:text:upper:{{plugin:invalid:op:value}}}}",
|
||||
wantErr: true,
|
||||
errContains: "unknown plugin namespace",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "empty template",
|
||||
template: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no substitutions needed",
|
||||
template: "plain text",
|
||||
want: "plain text",
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
vars map[string]string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
// Basic variable substitution
|
||||
{
|
||||
name: "simple variable",
|
||||
template: "Hello {{name}}!",
|
||||
vars: map[string]string{"name": "World"},
|
||||
want: "Hello World!",
|
||||
},
|
||||
{
|
||||
name: "multiple variables",
|
||||
template: "{{greeting}} {{name}}!",
|
||||
vars: map[string]string{
|
||||
"greeting": "Hello",
|
||||
"name": "World",
|
||||
},
|
||||
want: "Hello World!",
|
||||
},
|
||||
{
|
||||
name: "special input variable",
|
||||
template: "Content: {{input}}",
|
||||
input: "test content",
|
||||
want: "Content: test content",
|
||||
},
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ApplyTemplate(tt.template, tt.vars, tt.input)
|
||||
|
||||
// Check error cases
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ApplyTemplate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil && tt.errContains != "" {
|
||||
if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check result
|
||||
if got != tt.want {
|
||||
t.Errorf("ApplyTemplate() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Nested variable substitution
|
||||
{
|
||||
name: "nested variables",
|
||||
template: "{{outer{{inner}}}}",
|
||||
vars: map[string]string{
|
||||
"inner": "foo", // First resolution
|
||||
"outerfoo": "result", // Second resolution
|
||||
},
|
||||
want: "result",
|
||||
},
|
||||
|
||||
// Plugin operations
|
||||
{
|
||||
name: "simple text plugin",
|
||||
template: "{{plugin:text:upper:hello}}",
|
||||
want: "HELLO",
|
||||
},
|
||||
{
|
||||
name: "text plugin with variable",
|
||||
template: "{{plugin:text:upper:{{name}}}}",
|
||||
vars: map[string]string{"name": "world"},
|
||||
want: "WORLD",
|
||||
},
|
||||
{
|
||||
name: "plugin with dynamic operation",
|
||||
template: "{{plugin:text:{{operation}}:hello}}",
|
||||
vars: map[string]string{"operation": "upper"},
|
||||
want: "HELLO",
|
||||
},
|
||||
|
||||
// Multiple operations
|
||||
{
|
||||
name: "multiple plugins",
|
||||
template: "A:{{plugin:text:upper:hello}} B:{{plugin:text:lower:WORLD}}",
|
||||
want: "A:HELLO B:world",
|
||||
},
|
||||
{
|
||||
name: "nested plugins",
|
||||
template: "{{plugin:text:upper:{{plugin:text:lower:HELLO}}}}",
|
||||
want: "HELLO",
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "missing variable",
|
||||
template: "Hello {{name}}!",
|
||||
wantErr: true,
|
||||
errContains: "missing required variable",
|
||||
},
|
||||
{
|
||||
name: "unknown plugin",
|
||||
template: "{{plugin:invalid:op:value}}",
|
||||
wantErr: true,
|
||||
errContains: "unknown plugin namespace",
|
||||
},
|
||||
{
|
||||
name: "unknown plugin operation",
|
||||
template: "{{plugin:text:invalid:value}}",
|
||||
wantErr: true,
|
||||
errContains: "unknown text operation",
|
||||
},
|
||||
{
|
||||
name: "nested plugin error",
|
||||
template: "{{plugin:text:upper:{{plugin:invalid:op:value}}}}",
|
||||
wantErr: true,
|
||||
errContains: "unknown plugin namespace",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "empty template",
|
||||
template: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no substitutions needed",
|
||||
template: "plain text",
|
||||
want: "plain text",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ApplyTemplate(tt.template, tt.vars, tt.input)
|
||||
|
||||
// Check error cases
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ApplyTemplate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil && tt.errContains != "" {
|
||||
if !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check result
|
||||
if got != tt.want {
|
||||
t.Errorf("ApplyTemplate() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,53 +12,53 @@ type TextPlugin struct{}
|
||||
|
||||
// toTitle capitalizes a letter if it follows a non-letter, unless next char is space
|
||||
func toTitle(s string) string {
|
||||
// First lowercase everything
|
||||
lower := strings.ToLower(s)
|
||||
runes := []rune(lower)
|
||||
|
||||
for i := 0; i < len(runes); i++ {
|
||||
// Capitalize if previous char is non-letter AND
|
||||
// (we're at the end OR next char is not space)
|
||||
if (i == 0 || !unicode.IsLetter(runes[i-1])) {
|
||||
if i == len(runes)-1 || !unicode.IsSpace(runes[i+1]) {
|
||||
runes[i] = unicode.ToUpper(runes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(runes)
|
||||
// First lowercase everything
|
||||
lower := strings.ToLower(s)
|
||||
runes := []rune(lower)
|
||||
|
||||
for i := 0; i < len(runes); i++ {
|
||||
// Capitalize if previous char is non-letter AND
|
||||
// (we're at the end OR next char is not space)
|
||||
if i == 0 || !unicode.IsLetter(runes[i-1]) {
|
||||
if i == len(runes)-1 || !unicode.IsSpace(runes[i+1]) {
|
||||
runes[i] = unicode.ToUpper(runes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// Apply executes the requested text operation on the provided value
|
||||
func (p *TextPlugin) Apply(operation string, value string) (string, error) {
|
||||
debugf("TextPlugin: operation=%s value=%q", operation, value)
|
||||
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("text: empty input for operation %q", operation)
|
||||
}
|
||||
debugf("TextPlugin: operation=%s value=%q", operation, value)
|
||||
|
||||
switch operation {
|
||||
case "upper":
|
||||
result := strings.ToUpper(value)
|
||||
debugf("TextPlugin: upper result=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "lower":
|
||||
result := strings.ToLower(value)
|
||||
debugf("TextPlugin: lower result=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "title":
|
||||
result := toTitle(value)
|
||||
debugf("TextPlugin: title result=%q", result)
|
||||
return result, nil
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("text: empty input for operation %q", operation)
|
||||
}
|
||||
|
||||
case "trim":
|
||||
result := strings.TrimSpace(value)
|
||||
debugf("TextPlugin: trim result=%q", result)
|
||||
return result, nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("text: unknown text operation %q (supported: upper, lower, title, trim)", operation)
|
||||
}
|
||||
}
|
||||
switch operation {
|
||||
case "upper":
|
||||
result := strings.ToUpper(value)
|
||||
debugf("TextPlugin: upper result=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "lower":
|
||||
result := strings.ToLower(value)
|
||||
debugf("TextPlugin: lower result=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "title":
|
||||
result := toTitle(value)
|
||||
debugf("TextPlugin: title result=%q", result)
|
||||
return result, nil
|
||||
|
||||
case "trim":
|
||||
result := strings.TrimSpace(value)
|
||||
debugf("TextPlugin: trim result=%q", result)
|
||||
return result, nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("text: unknown text operation %q (supported: upper, lower, title, trim)", operation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,100 +5,100 @@ import (
|
||||
)
|
||||
|
||||
func TestTextPlugin(t *testing.T) {
|
||||
plugin := &TextPlugin{}
|
||||
plugin := &TextPlugin{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
// Upper tests
|
||||
{
|
||||
name: "upper basic",
|
||||
operation: "upper",
|
||||
value: "hello",
|
||||
want: "HELLO",
|
||||
},
|
||||
{
|
||||
name: "upper mixed case",
|
||||
operation: "upper",
|
||||
value: "hElLo",
|
||||
want: "HELLO",
|
||||
},
|
||||
|
||||
// Lower tests
|
||||
{
|
||||
name: "lower basic",
|
||||
operation: "lower",
|
||||
value: "HELLO",
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "lower mixed case",
|
||||
operation: "lower",
|
||||
value: "hElLo",
|
||||
want: "hello",
|
||||
},
|
||||
|
||||
// Title tests
|
||||
{
|
||||
name: "title basic",
|
||||
operation: "title",
|
||||
value: "hello world",
|
||||
want: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "title with apostrophe",
|
||||
operation: "title",
|
||||
value: "o'reilly's book",
|
||||
want: "O'Reilly's Book",
|
||||
},
|
||||
|
||||
// Trim tests
|
||||
{
|
||||
name: "trim spaces",
|
||||
operation: "trim",
|
||||
value: " hello ",
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "trim newlines",
|
||||
operation: "trim",
|
||||
value: "\nhello\n",
|
||||
want: "hello",
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "empty value",
|
||||
operation: "upper",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unknown operation",
|
||||
operation: "invalid",
|
||||
value: "test",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
value string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
// Upper tests
|
||||
{
|
||||
name: "upper basic",
|
||||
operation: "upper",
|
||||
value: "hello",
|
||||
want: "HELLO",
|
||||
},
|
||||
{
|
||||
name: "upper mixed case",
|
||||
operation: "upper",
|
||||
value: "hElLo",
|
||||
want: "HELLO",
|
||||
},
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := plugin.Apply(tt.operation, tt.value)
|
||||
|
||||
// Check error cases
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("TextPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Check successful cases
|
||||
if err == nil && got != tt.want {
|
||||
t.Errorf("TextPlugin.Apply() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// Lower tests
|
||||
{
|
||||
name: "lower basic",
|
||||
operation: "lower",
|
||||
value: "HELLO",
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "lower mixed case",
|
||||
operation: "lower",
|
||||
value: "hElLo",
|
||||
want: "hello",
|
||||
},
|
||||
|
||||
// Title tests
|
||||
{
|
||||
name: "title basic",
|
||||
operation: "title",
|
||||
value: "hello world",
|
||||
want: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "title with apostrophe",
|
||||
operation: "title",
|
||||
value: "o'reilly's book",
|
||||
want: "O'Reilly's Book",
|
||||
},
|
||||
|
||||
// Trim tests
|
||||
{
|
||||
name: "trim spaces",
|
||||
operation: "trim",
|
||||
value: " hello ",
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "trim newlines",
|
||||
operation: "trim",
|
||||
value: "\nhello\n",
|
||||
want: "hello",
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "empty value",
|
||||
operation: "upper",
|
||||
value: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unknown operation",
|
||||
operation: "invalid",
|
||||
value: "test",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := plugin.Apply(tt.operation, tt.value)
|
||||
|
||||
// Check error cases
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("TextPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Check successful cases
|
||||
if err == nil && got != tt.want {
|
||||
t.Errorf("TextPlugin.Apply() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
211
restapi/chat.go
Executable file
211
restapi/chat.go
Executable file
@@ -0,0 +1,211 @@
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
goopenai "github.com/sashabaranov/go-openai"
|
||||
|
||||
"github.com/danielmiessler/fabric/common"
|
||||
"github.com/danielmiessler/fabric/core"
|
||||
"github.com/danielmiessler/fabric/plugins/db/fsdb"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ChatHandler struct {
|
||||
registry *core.PluginRegistry
|
||||
db *fsdb.Db
|
||||
}
|
||||
|
||||
type PromptRequest struct {
|
||||
UserInput string `json:"userInput"`
|
||||
Vendor string `json:"vendor"`
|
||||
Model string `json:"model"`
|
||||
ContextName string `json:"contextName"`
|
||||
PatternName string `json:"patternName"`
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Prompts []PromptRequest `json:"prompts"`
|
||||
common.ChatOptions // Embed the ChatOptions from common package
|
||||
}
|
||||
|
||||
type StreamResponse struct {
|
||||
Type string `json:"type"` // "content", "error", "complete"
|
||||
Format string `json:"format"` // "markdown", "mermaid", "plain"
|
||||
Content string `json:"content"` // The actual content
|
||||
}
|
||||
|
||||
func NewChatHandler(r *gin.Engine, registry *core.PluginRegistry, db *fsdb.Db) *ChatHandler {
|
||||
handler := &ChatHandler{
|
||||
registry: registry,
|
||||
db: db,
|
||||
}
|
||||
|
||||
r.POST("/chat", handler.HandleChat)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *ChatHandler) HandleChat(c *gin.Context) {
|
||||
var request ChatRequest
|
||||
|
||||
if err := c.BindJSON(&request); err != nil {
|
||||
log.Printf("Error binding JSON: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request format: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Received chat request with %d prompts", len(request.Prompts))
|
||||
|
||||
// Set headers for SSE
|
||||
c.Writer.Header().Set("Content-Type", "text/readystream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
clientGone := c.Writer.CloseNotify()
|
||||
|
||||
for i, prompt := range request.Prompts {
|
||||
select {
|
||||
case <-clientGone:
|
||||
log.Printf("Client disconnected")
|
||||
return
|
||||
default:
|
||||
log.Printf("Processing prompt %d: Model=%s Pattern=%s Context=%s",
|
||||
i+1, prompt.Model, prompt.PatternName, prompt.ContextName)
|
||||
|
||||
// Create chat channel for streaming
|
||||
streamChan := make(chan string)
|
||||
|
||||
// Start chat processing in goroutine
|
||||
go func(p PromptRequest) {
|
||||
defer close(streamChan)
|
||||
|
||||
chatter, err := h.registry.GetChatter(p.Model, 2048, false, false)
|
||||
if err != nil {
|
||||
log.Printf("Error creating chatter: %v", err)
|
||||
streamChan <- fmt.Sprintf("Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
chatReq := &common.ChatRequest{
|
||||
Message: &goopenai.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
Content: p.UserInput,
|
||||
},
|
||||
PatternName: p.PatternName,
|
||||
ContextName: p.ContextName,
|
||||
}
|
||||
|
||||
opts := &common.ChatOptions{
|
||||
Model: p.Model,
|
||||
Temperature: request.Temperature,
|
||||
TopP: request.TopP,
|
||||
FrequencyPenalty: request.FrequencyPenalty,
|
||||
PresencePenalty: request.PresencePenalty,
|
||||
}
|
||||
|
||||
session, err := chatter.Send(chatReq, opts)
|
||||
if err != nil {
|
||||
log.Printf("Error from chatter.Send: %v", err)
|
||||
streamChan <- fmt.Sprintf("Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
log.Printf("No session returned from chatter.Send")
|
||||
streamChan <- "Error: No response from model"
|
||||
return
|
||||
}
|
||||
|
||||
// Get the last message from the session
|
||||
lastMsg := session.GetLastMessage()
|
||||
if lastMsg != nil {
|
||||
streamChan <- lastMsg.Content
|
||||
} else {
|
||||
log.Printf("No message content in session")
|
||||
streamChan <- "Error: No response content"
|
||||
}
|
||||
}(prompt)
|
||||
|
||||
// Read from streamChan and write to client
|
||||
for content := range streamChan {
|
||||
select {
|
||||
case <-clientGone:
|
||||
return
|
||||
default:
|
||||
if strings.HasPrefix(content, "Error:") {
|
||||
response := StreamResponse{
|
||||
Type: "error",
|
||||
Format: "plain",
|
||||
Content: content,
|
||||
}
|
||||
if err := writeSSEResponse(c.Writer, response); err != nil {
|
||||
log.Printf("Error writing error response: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
response := StreamResponse{
|
||||
Type: "content",
|
||||
Format: detectFormat(content),
|
||||
Content: content,
|
||||
}
|
||||
if err := writeSSEResponse(c.Writer, response); err != nil {
|
||||
log.Printf("Error writing content response: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Signal completion of this prompt
|
||||
completeResponse := StreamResponse{
|
||||
Type: "complete",
|
||||
Format: "plain",
|
||||
Content: "",
|
||||
}
|
||||
if err := writeSSEResponse(c.Writer, completeResponse); err != nil {
|
||||
log.Printf("Error writing completion response: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSSEResponse(w gin.ResponseWriter, response StreamResponse) error {
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling response: %v", err)
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", string(data)); err != nil {
|
||||
return fmt.Errorf("error writing response: %v", err)
|
||||
}
|
||||
|
||||
w.(http.Flusher).Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectFormat(content string) string {
|
||||
if strings.HasPrefix(content, "graph TD") ||
|
||||
strings.HasPrefix(content, "gantt") ||
|
||||
strings.HasPrefix(content, "flowchart") ||
|
||||
strings.HasPrefix(content, "sequenceDiagram") ||
|
||||
strings.HasPrefix(content, "classDiagram") ||
|
||||
strings.HasPrefix(content, "stateDiagram") {
|
||||
return "mermaid"
|
||||
}
|
||||
if strings.Contains(content, "```") ||
|
||||
strings.Contains(content, "#") ||
|
||||
strings.Contains(content, "*") ||
|
||||
strings.Contains(content, "_") ||
|
||||
strings.Contains(content, "-") {
|
||||
return "markdown"
|
||||
}
|
||||
return "plain"
|
||||
}
|
||||
124
restapi/configuration.go
Executable file
124
restapi/configuration.go
Executable file
@@ -0,0 +1,124 @@
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/danielmiessler/fabric/plugins/db/fsdb"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ConfigHandler defines the handler for configuration-related operations
|
||||
type ConfigHandler struct {
|
||||
db *fsdb.Db
|
||||
// configurations *fsdb.EnvFilePath("$HOME/.config/fabric/.env")
|
||||
}
|
||||
|
||||
func NewConfigHandler(r *gin.Engine, db *fsdb.Db) *ConfigHandler {
|
||||
handler := &ConfigHandler{
|
||||
db: db,
|
||||
// configurations: db.Configurations,
|
||||
}
|
||||
|
||||
r.GET("/config", handler.GetConfig)
|
||||
r.POST("/config/update", handler.UpdateConfig)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": ".env file not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.db.IsEnvFileExists() {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"openai": "",
|
||||
"anthropic": "",
|
||||
"groq": "",
|
||||
"mistral": "",
|
||||
"gemini": "",
|
||||
"ollama": "",
|
||||
"openrouter": "",
|
||||
"silicon": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.db.LoadEnvFile()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config := map[string]string{
|
||||
"openai": os.Getenv("OPENAI_API_KEY"),
|
||||
"anthropic": os.Getenv("ANTHROPIC_API_KEY"),
|
||||
"groq": os.Getenv("GROQ_API_KEY"),
|
||||
"mistral": os.Getenv("MISTRAL_API_KEY"),
|
||||
"gemini": os.Getenv("GEMINI_API_KEY"),
|
||||
"ollama": os.Getenv("OLLAMA_URL"),
|
||||
"openrouter": os.Getenv("OPENROUTER_API_KEY"),
|
||||
"silicon": os.Getenv("SILICON_API_KEY"),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not initialized"})
|
||||
return
|
||||
}
|
||||
|
||||
var config struct {
|
||||
OpenAIApiKey string `json:"openai_api_key"`
|
||||
AnthropicApiKey string `json:"anthropic_api_key"`
|
||||
GroqApiKey string `json:"groq_api_key"`
|
||||
MistralApiKey string `json:"mistral_api_key"`
|
||||
GeminiApiKey string `json:"gemini_api_key"`
|
||||
OllamaURL string `json:"ollama_url"`
|
||||
OpenRouterApiKey string `json:"openrouter_api_key"`
|
||||
SiliconApiKey string `json:"silicon_api_key"`
|
||||
}
|
||||
|
||||
if err := c.BindJSON(&config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
envVars := map[string]string{
|
||||
"OPENAI_API_KEY": config.OpenAIApiKey,
|
||||
"ANTHROPIC_API_KEY": config.AnthropicApiKey,
|
||||
"GROQ_API_KEY": config.GroqApiKey,
|
||||
"MISTRAL_API_KEY": config.MistralApiKey,
|
||||
"GEMINI_API_KEY": config.GeminiApiKey,
|
||||
"OLLAMA_URL": config.OllamaURL,
|
||||
"OPENROUTER_API_KEY": config.OpenRouterApiKey,
|
||||
"SILICON_API_KEY": config.SiliconApiKey,
|
||||
}
|
||||
|
||||
var envContent strings.Builder
|
||||
for key, value := range envVars {
|
||||
if value != "" {
|
||||
envContent.WriteString(fmt.Sprintf("%s=%s\n", key, value))
|
||||
os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration to file
|
||||
if err := h.db.SaveEnv(envContent.String()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.LoadEnvFile(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Configuration updated successfully"})
|
||||
}
|
||||
45
restapi/models.go
Executable file
45
restapi/models.go
Executable file
@@ -0,0 +1,45 @@
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"github.com/danielmiessler/fabric/plugins/ai"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ModelsHandler struct {
|
||||
vendorManager *ai.VendorsManager
|
||||
}
|
||||
|
||||
func NewModelsHandler(r *gin.Engine, vendorManager *ai.VendorsManager) {
|
||||
handler := &ModelsHandler{
|
||||
vendorManager: vendorManager,
|
||||
}
|
||||
|
||||
r.GET("/models/names", handler.GetModelNames)
|
||||
}
|
||||
|
||||
func (h *ModelsHandler) GetModelNames(c *gin.Context) {
|
||||
vendorsModels, err := h.vendorManager.GetModels()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "Server failed to retrieve model names"})
|
||||
return
|
||||
}
|
||||
|
||||
response := make(map[string]interface{})
|
||||
vendors := make(map[string][]string)
|
||||
|
||||
for _, groupItems := range vendorsModels.GroupsItems {
|
||||
vendors[groupItems.Group] = groupItems.Items
|
||||
}
|
||||
|
||||
response["models"] = h.getAllModelNames(vendorsModels)
|
||||
response["vendors"] = vendors
|
||||
c.JSON(200, response)
|
||||
}
|
||||
|
||||
func (h *ModelsHandler) getAllModelNames(vendorsModels *ai.VendorsModels) []string {
|
||||
var allModelNames []string
|
||||
for _, groupItems := range vendorsModels.GroupsItems {
|
||||
allModelNames = append(allModelNames, groupItems.Items...)
|
||||
}
|
||||
return allModelNames
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func NewPatternsHandler(r *gin.Engine, patterns *fsdb.PatternsEntity) (ret *Patt
|
||||
func (h *PatternsHandler) Get(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
variables := make(map[string]string) // Assuming variables are passed somehow
|
||||
input := "" // Assuming input is passed somehow
|
||||
input := "" // Assuming input is passed somehow
|
||||
pattern, err := h.patterns.GetApplyVariables(name, variables, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -17,6 +17,9 @@ func Serve(registry *core.PluginRegistry, address string) (err error) {
|
||||
NewPatternsHandler(r, fabricDb.Patterns)
|
||||
NewContextsHandler(r, fabricDb.Contexts)
|
||||
NewSessionsHandler(r, fabricDb.Sessions)
|
||||
NewChatHandler(r, registry, fabricDb)
|
||||
NewConfigHandler(r, fabricDb)
|
||||
NewModelsHandler(r, registry.VendorManager)
|
||||
|
||||
// Start server
|
||||
err = r.Run(address)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package main
|
||||
|
||||
var version = "v1.4.110"
|
||||
var version = "v1.4.119"
|
||||
|
||||
11
web/.github/dependabot.yml
vendored
Normal file
11
web/.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm", "pnpm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
0
web/.npmrc
Normal file
0
web/.npmrc
Normal file
4
web/.prettierignore
Normal file
4
web/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
8
web/.prettierrc
Normal file
8
web/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
120
web/.vscode/settings.json
vendored
Normal file
120
web/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"prettier.documentSelectors": [
|
||||
"**/*.svelte"
|
||||
],
|
||||
"tailwindCSS.classAttributes": [
|
||||
"class",
|
||||
"accent",
|
||||
"active",
|
||||
"animIndeterminate",
|
||||
"aspectRatio",
|
||||
"background",
|
||||
"badge",
|
||||
"bgBackdrop",
|
||||
"bgDark",
|
||||
"bgDrawer",
|
||||
"bgLight",
|
||||
"blur",
|
||||
"border",
|
||||
"button",
|
||||
"buttonAction",
|
||||
"buttonBack",
|
||||
"buttonClasses",
|
||||
"buttonComplete",
|
||||
"buttonDismiss",
|
||||
"buttonNeutral",
|
||||
"buttonNext",
|
||||
"buttonPositive",
|
||||
"buttonTextCancel",
|
||||
"buttonTextConfirm",
|
||||
"buttonTextFirst",
|
||||
"buttonTextLast",
|
||||
"buttonTextNext",
|
||||
"buttonTextPrevious",
|
||||
"buttonTextSubmit",
|
||||
"caretClosed",
|
||||
"caretOpen",
|
||||
"chips",
|
||||
"color",
|
||||
"controlSeparator",
|
||||
"controlVariant",
|
||||
"cursor",
|
||||
"display",
|
||||
"element",
|
||||
"fill",
|
||||
"fillDark",
|
||||
"fillLight",
|
||||
"flex",
|
||||
"flexDirection",
|
||||
"gap",
|
||||
"gridColumns",
|
||||
"height",
|
||||
"hover",
|
||||
"inactive",
|
||||
"indent",
|
||||
"justify",
|
||||
"meter",
|
||||
"padding",
|
||||
"position",
|
||||
"regionAnchor",
|
||||
"regionBackdrop",
|
||||
"regionBody",
|
||||
"regionCaption",
|
||||
"regionCaret",
|
||||
"regionCell",
|
||||
"regionChildren",
|
||||
"regionChipList",
|
||||
"regionChipWrapper",
|
||||
"regionCone",
|
||||
"regionContent",
|
||||
"regionControl",
|
||||
"regionDefault",
|
||||
"regionDrawer",
|
||||
"regionFoot",
|
||||
"regionFootCell",
|
||||
"regionFooter",
|
||||
"regionHead",
|
||||
"regionHeadCell",
|
||||
"regionHeader",
|
||||
"regionIcon",
|
||||
"regionInput",
|
||||
"regionInterface",
|
||||
"regionInterfaceText",
|
||||
"regionLabel",
|
||||
"regionLead",
|
||||
"regionLegend",
|
||||
"regionList",
|
||||
"regionListItem",
|
||||
"regionNavigation",
|
||||
"regionPage",
|
||||
"regionPanel",
|
||||
"regionRowHeadline",
|
||||
"regionRowMain",
|
||||
"regionSummary",
|
||||
"regionSymbol",
|
||||
"regionTab",
|
||||
"regionTrail",
|
||||
"ring",
|
||||
"rounded",
|
||||
"select",
|
||||
"shadow",
|
||||
"slotDefault",
|
||||
"slotFooter",
|
||||
"slotHeader",
|
||||
"slotLead",
|
||||
"slotMessage",
|
||||
"slotMeta",
|
||||
"slotPageContent",
|
||||
"slotPageFooter",
|
||||
"slotPageHeader",
|
||||
"slotSidebarLeft",
|
||||
"slotSidebarRight",
|
||||
"slotTrail",
|
||||
"spacing",
|
||||
"text",
|
||||
"track",
|
||||
"transition",
|
||||
"width",
|
||||
"zIndex"
|
||||
]
|
||||
}
|
||||
13
web/README.md
Normal file
13
web/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## The Fabric Web App
|
||||
[Installing](#Installing)|[Todos](#Todos)|[Collaborators](#Collaborators)
|
||||
|
||||
This is the web app for Fabric. It is built using [Svelte](https://svelte.dev/), [SkeletonUI](https://skeleton.dev/), and [Mdsvex](https://mdsvex.pngwn.io/).
|
||||
|
||||
The goal of this app is to not only provide a user interface for Fabric, but also a out-of-the-box website for those who want to get started with web development or blogging. The tech stack is minimal and (I hope) the code is easy to read and understand. One thing I kept in mind when making this app was to make it easy for beginners to get started with web development. You can use this app as a GUI interface for Fabric, a ready to go blog-site, or a website template for your own projects. I hope you find it useful!
|
||||
|
||||

|
||||
|
||||
### Installing
|
||||
It can be installed by navigating to the `web` directory and using `npm install`, `pnpm install`, or your favorite package manager. Then simply run `npm run dev` or your equivalent command to start the app. *You will need to run fabric in a seperate terminal with the `fabric --serve` command.*
|
||||
|
||||
|
||||
21
web/SECURITY.md
Normal file
21
web/SECURITY.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use this section to tell people how to report a vulnerability.
|
||||
|
||||
Tell them where to go, how often they can expect to get an update on a
|
||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||
declined, etc.
|
||||
38
web/STD-README.md
Normal file
38
web/STD-README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm create svelte@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm create svelte@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||
32
web/eslint.config.js
Normal file
32
web/eslint.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import eslint from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tseslint.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||
}
|
||||
);
|
||||
19
web/jsconfig.json
Normal file
19
web/jsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
4
web/markdown.d.ts
vendored
Normal file
4
web/markdown.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/* declare module '*.md' {
|
||||
const component: import('svelte').ComponentType;
|
||||
export default component;
|
||||
} */
|
||||
102
web/my-custom-theme.ts
Normal file
102
web/my-custom-theme.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
|
||||
import type { CustomThemeConfig } from '@skeletonlabs/tw-plugin';
|
||||
|
||||
export const myCustomTheme: CustomThemeConfig = {
|
||||
name: 'my-custom-theme',
|
||||
properties: {
|
||||
// =~= Theme Properties =~=
|
||||
"--theme-font-family-base": `system-ui`,
|
||||
"--theme-font-family-heading": `system-ui`,
|
||||
"--theme-font-color-base": "var(--color-primary-800)",
|
||||
"--theme-font-color-dark": "var(--color-primary-300)",
|
||||
"--theme-rounded-base": "9999px",
|
||||
"--theme-rounded-container": "8px",
|
||||
"--theme-border-base": "1px",
|
||||
// =~= Theme On-X Colors =~=
|
||||
"--on-primary": "0 0 0",
|
||||
"--on-secondary": "0 0 0",
|
||||
"--on-tertiary": "0 0 0",
|
||||
"--on-success": "0 0 0",
|
||||
"--on-warning": "0 0 0",
|
||||
"--on-error": "0 0 0",
|
||||
"--on-surface": "0 0 0",
|
||||
// =~= Theme Colors =~=
|
||||
// primary | #613bf7
|
||||
"--color-primary-50": "231 226 254", // #e7e2fe
|
||||
"--color-primary-100": "223 216 253", // #dfd8fd
|
||||
"--color-primary-200": "216 206 253", // #d8cefd
|
||||
"--color-primary-300": "192 177 252", // #c0b1fc
|
||||
"--color-primary-400": "144 118 249", // #9076f9
|
||||
"--color-primary-500": "97 59 247", // #613bf7
|
||||
"--color-primary-600": "87 53 222", // #5735de
|
||||
"--color-primary-700": "73 44 185", // #492cb9
|
||||
"--color-primary-800": "58 35 148", // #3a2394
|
||||
"--color-primary-900": "48 29 121", // #301d79
|
||||
// secondary | #9de1ae
|
||||
"--color-secondary-50": "240 251 243", // #f0fbf3
|
||||
"--color-secondary-100": "235 249 239", // #ebf9ef
|
||||
"--color-secondary-200": "231 248 235", // #e7f8eb
|
||||
"--color-secondary-300": "216 243 223", // #d8f3df
|
||||
"--color-secondary-400": "186 234 198", // #baeac6
|
||||
"--color-secondary-500": "157 225 174", // #9de1ae
|
||||
"--color-secondary-600": "141 203 157", // #8dcb9d
|
||||
"--color-secondary-700": "118 169 131", // #76a983
|
||||
"--color-secondary-800": "94 135 104", // #5e8768
|
||||
"--color-secondary-900": "77 110 85", // #4d6e55
|
||||
// tertiary | #3fa0a6
|
||||
"--color-tertiary-50": "226 241 242", // #e2f1f2
|
||||
"--color-tertiary-100": "217 236 237", // #d9eced
|
||||
"--color-tertiary-200": "207 231 233", // #cfe7e9
|
||||
"--color-tertiary-300": "178 217 219", // #b2d9db
|
||||
"--color-tertiary-400": "121 189 193", // #79bdc1
|
||||
"--color-tertiary-500": "63 160 166", // #3fa0a6
|
||||
"--color-tertiary-600": "57 144 149", // #399095
|
||||
"--color-tertiary-700": "47 120 125", // #2f787d
|
||||
"--color-tertiary-800": "38 96 100", // #266064
|
||||
"--color-tertiary-900": "31 78 81", // #1f4e51
|
||||
// success | #37b3fc
|
||||
"--color-success-50": "225 244 255", // #e1f4ff
|
||||
"--color-success-100": "215 240 254", // #d7f0fe
|
||||
"--color-success-200": "205 236 254", // #cdecfe
|
||||
"--color-success-300": "175 225 254", // #afe1fe
|
||||
"--color-success-400": "115 202 253", // #73cafd
|
||||
"--color-success-500": "55 179 252", // #37b3fc
|
||||
"--color-success-600": "50 161 227", // #32a1e3
|
||||
"--color-success-700": "41 134 189", // #2986bd
|
||||
"--color-success-800": "33 107 151", // #216b97
|
||||
"--color-success-900": "27 88 123", // #1b587b
|
||||
// warning | #d209f8
|
||||
"--color-warning-50": "248 218 254", // #f8dafe
|
||||
"--color-warning-100": "246 206 254", // #f6cefe
|
||||
"--color-warning-200": "244 194 253", // #f4c2fd
|
||||
"--color-warning-300": "237 157 252", // #ed9dfc
|
||||
"--color-warning-400": "224 83 250", // #e053fa
|
||||
"--color-warning-500": "210 9 248", // #d209f8
|
||||
"--color-warning-600": "189 8 223", // #bd08df
|
||||
"--color-warning-700": "158 7 186", // #9e07ba
|
||||
"--color-warning-800": "126 5 149", // #7e0595
|
||||
"--color-warning-900": "103 4 122", // #67047a
|
||||
// error | #90df16
|
||||
"--color-error-50": "238 250 220", // #eefadc
|
||||
"--color-error-100": "233 249 208", // #e9f9d0
|
||||
"--color-error-200": "227 247 197", // #e3f7c5
|
||||
"--color-error-300": "211 242 162", // #d3f2a2
|
||||
"--color-error-400": "177 233 92", // #b1e95c
|
||||
"--color-error-500": "144 223 22", // #90df16
|
||||
"--color-error-600": "130 201 20", // #82c914
|
||||
"--color-error-700": "108 167 17", // #6ca711
|
||||
"--color-error-800": "86 134 13", // #56860d
|
||||
"--color-error-900": "71 109 11", // #476d0b
|
||||
// surface | #46a1ed
|
||||
"--color-surface-50": "227 241 252", // #e3f1fc
|
||||
"--color-surface-100": "218 236 251", // #daecfb
|
||||
"--color-surface-200": "209 232 251", // #d1e8fb
|
||||
"--color-surface-300": "181 217 248", // #b5d9f8
|
||||
"--color-surface-400": "126 189 242", // #7ebdf2
|
||||
"--color-surface-500": "70 161 237", // #46a1ed
|
||||
"--color-surface-600": "63 145 213", // #3f91d5
|
||||
"--color-surface-700": "53 121 178", // #3579b2
|
||||
"--color-surface-800": "42 97 142", // #2a618e
|
||||
"--color-surface-900": "34 79 116", // #224f74
|
||||
}
|
||||
}
|
||||
7397
web/package-lock.json
generated
Normal file
7397
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
web/package.json
Normal file
53
web/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "terminal-blog",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@skeletonlabs/skeleton": "^2.8.0",
|
||||
"@skeletonlabs/tw-plugin": "^0.3.1",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.8.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/node": "^20.10.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"lucide-svelte": "^0.309.0",
|
||||
"mdsvex": "^0.11.0",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-load-config": "^5.0.2",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"svelte-youtube-embed": "^0.3.3",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3",
|
||||
"vite-plugin-tailwind-purgecss": "^0.2.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.5.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cn": "^0.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.10.0",
|
||||
"marked": "^15.0.1",
|
||||
"rehype": "^13.0.2",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"rehype-unwrap-images": "^1.0.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"youtube-transcript": "^1.2.1"
|
||||
}
|
||||
}
|
||||
2925
web/pnpm-lock.yaml
generated
Normal file
2925
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
web/postcss.config.cjs
Normal file
6
web/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
22
web/rollup.config.js
Normal file
22
web/rollup.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import svelte from 'rollup-plugin-svelte';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
|
||||
export default {
|
||||
input: 'src/+page.js',
|
||||
output: {
|
||||
file: 'public/bundle.js',
|
||||
format: 'iife',
|
||||
name: 'app'
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
// svelte options
|
||||
extensions: [".svelte", ".svx", ".md"],
|
||||
preprocess: mdsvex()
|
||||
}),
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ['svelte']
|
||||
})
|
||||
]
|
||||
};
|
||||
134
web/src/app.css
Normal file
134
web/src/app.css
Normal file
@@ -0,0 +1,134 @@
|
||||
/* @tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Go Mono', 'Fira Sans', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'Fira Code', monospace;
|
||||
--column-width: 42rem;
|
||||
--column-margin-top: 4rem;
|
||||
} */
|
||||
|
||||
/* Light theme variables */
|
||||
/* :root {
|
||||
--background: hsl(249, 81%, 85%);
|
||||
--foreground: hsl(229, 65%, 29%);
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71.4% 4.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71.4% 4.1%;
|
||||
--primary: hsl(262.1 83.3% 57.8%);
|
||||
--primary-foreground: hsl(274, 100%, 90%);
|
||||
--secondary: hsl(173, 74%, 68%);
|
||||
--secondary-foreground: hsl(195, 100%, 90%);
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
--accent: hsl(220, 37%, 49%);
|
||||
--accent-foreground: 220.9 39.3% 11%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 262.1 83.3% 57.8%;
|
||||
--radius: 0.5rem;
|
||||
} */
|
||||
|
||||
/* Dark theme variables */
|
||||
/* .dark {
|
||||
--background: 224 71.4% 4.1%;
|
||||
--foreground: 210 20% 98%;
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 20% 98%;
|
||||
--primary: 263.4 70% 50.4%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--ring: 263.4 70% 50.4%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
} */
|
||||
|
||||
/* Enhanced Typography */
|
||||
/* h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl lg:text-5xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-3xl lg:text-4xl;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-2xl lg:text-3xl;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply leading-7;
|
||||
}
|
||||
*/
|
||||
/* Links */
|
||||
/* a {
|
||||
@apply text-primary hover:text-primary/80 transition-colors;
|
||||
} */
|
||||
|
||||
/* Code blocks */
|
||||
/* pre {
|
||||
@apply p-4 rounded-lg bg-muted/50 font-mono text-sm;
|
||||
}
|
||||
|
||||
code {
|
||||
@apply font-mono text-sm;
|
||||
}
|
||||
*/
|
||||
/* Terminal specific styles */
|
||||
/* .terminal-window {
|
||||
@apply rounded-lg border bg-card shadow-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.terminal-text {
|
||||
@apply font-mono text-sm;
|
||||
}
|
||||
*/
|
||||
/* Form elements */
|
||||
/* input, textarea, select {
|
||||
@apply rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
||||
}
|
||||
} */
|
||||
|
||||
/* Custom scrollbar */
|
||||
/* ::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-muted;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-muted-foreground/30 rounded-full hover:bg-muted-foreground/50 transition-colors;
|
||||
} */
|
||||
9
web/src/app.d.ts
vendored
Normal file
9
web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
// interface Locals {}
|
||||
interface PageData {}
|
||||
// interface Error {}
|
||||
// interface Platform {}
|
||||
}
|
||||
12
web/src/app.html
Normal file
12
web/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" data-theme="my-custom-theme">
|
||||
<div style="display: contents" class="h-full overflow-hidden">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
25
web/src/app.postcss
Normal file
25
web/src/app.postcss
Normal file
@@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind variants;
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply h-full overflow-hidden;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
@apply font-mono text-sm;
|
||||
}
|
||||
|
||||
.terminal-input {
|
||||
@apply font-mono text-sm;
|
||||
}
|
||||
|
||||
/* Skeleton theme overrides */
|
||||
:root [data-theme='skeleton'] {
|
||||
--theme-font-family-base: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
--theme-font-family-heading: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
7
web/src/index.test.ts
Normal file
7
web/src/index.test.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
26
web/src/lib/components/ui/button/button.svelte
Normal file
26
web/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/types/utils";
|
||||
import { buttonVariants } from "./index.js";
|
||||
|
||||
let className = undefined;
|
||||
export let variant = "";
|
||||
export let size = "default";
|
||||
export { className as class };
|
||||
|
||||
$: classes = cn(
|
||||
buttonVariants.base,
|
||||
buttonVariants.variants.variant[variant as keyof typeof buttonVariants.variants.variant],
|
||||
buttonVariants.variants.size[size as keyof typeof buttonVariants.variants.size],
|
||||
className
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={classes}
|
||||
type="button"
|
||||
on:click
|
||||
on:keydown
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
31
web/src/lib/components/ui/button/index.js
Normal file
31
web/src/lib/components/ui/button/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import Root from "./button.svelte";
|
||||
|
||||
const buttonVariants = {
|
||||
base: "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||
outline: "border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
Root,
|
||||
Root as Button,
|
||||
buttonVariants
|
||||
};
|
||||
32
web/src/lib/components/ui/buymeacoffee/BuyMeCoffee.svelte
Normal file
32
web/src/lib/components/ui/buymeacoffee/BuyMeCoffee.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
export let url: string = 'https://www.buymeacoffee.com/johnconnor.sec';
|
||||
export let text: string = 'Buy me a coffee';
|
||||
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm px-3 py-1.5 btn variant-filled-tertiary hover:variant-filled-secondary transition-all duration-200 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="transition-transform duration-200 group-hover:rotate-12"
|
||||
>
|
||||
<path d="M17 8h1a4 4 0 1 1 0 8h-1" />
|
||||
<path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z" />
|
||||
<line x1="6" y1="2" x2="6" y2="4" />
|
||||
<line x1="10" y1="2" x2="10" y2="4" />
|
||||
<line x1="14" y1="2" x2="14" y2="4" />
|
||||
</svg>
|
||||
{text}
|
||||
</a>
|
||||
6
web/src/lib/components/ui/input/index.ts
Normal file
6
web/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from "./input.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
33
web/src/lib/components/ui/input/input.svelte
Normal file
33
web/src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/types/utils";
|
||||
let className: string | undefined = undefined;
|
||||
export let value = undefined;
|
||||
export { className as class };
|
||||
export let readonly = undefined;
|
||||
</script>
|
||||
|
||||
<input
|
||||
class={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-lg transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{readonly}
|
||||
on:blur
|
||||
on:change
|
||||
on:click
|
||||
on:focus
|
||||
on:focusin
|
||||
on:focusout
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:mouseover
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:mousemove
|
||||
on:paste
|
||||
on:input
|
||||
on:wheel|passive
|
||||
{...$$restProps}
|
||||
/>
|
||||
6
web/src/lib/components/ui/label/index.js
Normal file
6
web/src/lib/components/ui/label/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Root as Label
|
||||
};
|
||||
15
web/src/lib/components/ui/label/label.svelte
Normal file
15
web/src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
import { cn } from "$lib/types/utils.ts";
|
||||
let className = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<label
|
||||
class={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
6
web/src/lib/components/ui/select/index.js
Normal file
6
web/src/lib/components/ui/select/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from "./select.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Root as Select
|
||||
};
|
||||
30
web/src/lib/components/ui/select/select-content.svelte
Normal file
30
web/src/lib/components/ui/select/select-content.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script>
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
let className = undefined;
|
||||
export let sideOffset = 4;
|
||||
export let position = "popper";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Content {position} {sideOffset} {...$$restProps}>
|
||||
<div
|
||||
class={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</SelectPrimitive.Content>
|
||||
</div>
|
||||
16
web/src/lib/components/ui/select/select-item.svelte
Normal file
16
web/src/lib/components/ui/select/select-item.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let className = undefined;
|
||||
export let value;
|
||||
export let disabled = false;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<option
|
||||
{value}
|
||||
{disabled}
|
||||
class={cn("relative flex w-full cursor-default select-none py-1.5 pl-2 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)}
|
||||
>
|
||||
<slot />
|
||||
</option>
|
||||
10
web/src/lib/components/ui/select/select-label.svelte
Normal file
10
web/src/lib/components/ui/select/select-label.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/types/utils.ts";
|
||||
let className = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Label class={cn("px-2 py-1.5 text-sm variant-filled-secondary font-semibold", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</SelectPrimitive.Label>
|
||||
8
web/src/lib/components/ui/select/select-separator.svelte
Normal file
8
web/src/lib/components/ui/select/select-separator.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script>
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/types/utils.ts";
|
||||
let className = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Separator class={cn("bg-muted -mx-1 my-1 h-px", className)} {...$$restProps} />
|
||||
20
web/src/lib/components/ui/select/select-trigger.svelte
Normal file
20
web/src/lib/components/ui/select/select-trigger.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { ChevronDown } from "lucide-svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
let className = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
class={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
<ChevronDown class="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
18
web/src/lib/components/ui/select/select-value.svelte
Normal file
18
web/src/lib/components/ui/select/select-value.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
type $$Props = SelectPrimitive.ValueProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
export let placeholder: $$Props["placeholder"] = undefined;
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Value
|
||||
class={cn("text-sm", className)}
|
||||
{placeholder}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</SelectPrimitive.Value>
|
||||
24
web/src/lib/components/ui/select/select.svelte
Normal file
24
web/src/lib/components/ui/select/select.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/types/utils.ts";
|
||||
import { ChevronDown } from "lucide-svelte";
|
||||
|
||||
export let value: any = undefined;
|
||||
export let disabled = false;
|
||||
let className = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<select
|
||||
{disabled}
|
||||
bind:value
|
||||
class={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm shadow-lg ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 appearance-none",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</select>
|
||||
<ChevronDown class="absolute right-3 top-2.5 h-4 w-4 opacity-50 pointer-events-none" />
|
||||
</div>
|
||||
6
web/src/lib/components/ui/slider/index.js
Normal file
6
web/src/lib/components/ui/slider/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from "./slider.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider,
|
||||
};
|
||||
89
web/src/lib/components/ui/slider/slider.svelte
Normal file
89
web/src/lib/components/ui/slider/slider.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script>
|
||||
import { cn } from "$lib/types/utils.ts";
|
||||
|
||||
let className = undefined;
|
||||
export let value = 0;
|
||||
export let min = 0;
|
||||
export let max = 100;
|
||||
export let step = 1;
|
||||
export { className as class };
|
||||
|
||||
let sliderEl;
|
||||
let isDragging = false;
|
||||
|
||||
$: percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
function handleMouseDown(e) {
|
||||
isDragging = true;
|
||||
updateValue(e);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
function handleMouseMove(e) {
|
||||
if (!isDragging) return;
|
||||
updateValue(e);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isDragging = false;
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
function updateValue(e) {
|
||||
if (!sliderEl) return;
|
||||
const rect = sliderEl.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
const rawValue = min + (max - min) * pos;
|
||||
const steppedValue = Math.round(rawValue / step) * step;
|
||||
value = Math.max(min, Math.min(max, steppedValue));
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
value = Math.max(min, value - step);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
value = Math.min(max, value + step);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
value = min;
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
value = max;
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={sliderEl}
|
||||
class={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
on:mousedown={handleMouseDown}
|
||||
on:keydown={handleKeyDown}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
>
|
||||
<div class="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<div
|
||||
class="absolute h-full bg-primary transition-all"
|
||||
style="width: {percentage}%"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute h-4 w-4 rounded-full border border-secondary bg-primary-500 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
style="left: calc({percentage}% - 0.5rem)"
|
||||
/>
|
||||
</div>
|
||||
9
web/src/lib/components/ui/spinner/spinner.svelte
Normal file
9
web/src/lib/components/ui/spinner/spinner.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/types/utils';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
let className: string | undefined = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<Loader2 class={cn('h-4 w-4 animate-spin', className)} {...$$restProps} />
|
||||
66
web/src/lib/components/ui/tag-list/TagList.svelte
Normal file
66
web/src/lib/components/ui/tag-list/TagList.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { cn } from '$lib/types/utils';
|
||||
|
||||
export let tags: string[] = [];
|
||||
export let tagsPerPage = 5;
|
||||
export let className: string | undefined = undefined;
|
||||
|
||||
let currentPage = 0;
|
||||
let containerWidth: number;
|
||||
|
||||
$: totalPages = Math.ceil(tags.length / tagsPerPage);
|
||||
$: startIndex = currentPage * tagsPerPage;
|
||||
$: endIndex = Math.min(startIndex + tagsPerPage, tags.length);
|
||||
$: visibleTags = tags.slice(startIndex, endIndex);
|
||||
$: canGoBack = currentPage > 0;
|
||||
$: canGoForward = currentPage < totalPages - 1;
|
||||
|
||||
function nextPage() {
|
||||
if (canGoForward) {
|
||||
currentPage++;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (canGoBack) {
|
||||
currentPage--;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('relative flex items-center gap-2', className)} bind:clientWidth={containerWidth}>
|
||||
{#if totalPages > 1 && canGoBack}
|
||||
<button
|
||||
on:click={prevPage}
|
||||
class="flex h-6 w-6 items-center justify-center rounded-md border bg-background hover:bg-muted"
|
||||
transition:slide
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
<span class="sr-only">Previous page</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each visibleTags as tag (tag)}
|
||||
<a
|
||||
href="/tags/{tag}"
|
||||
class="inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors hover:bg-muted"
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if totalPages > 1 && canGoForward}
|
||||
<button
|
||||
on:click={nextPage}
|
||||
class="flex h-6 w-6 items-center justify-center rounded-md border bg-background hover:bg-muted"
|
||||
transition:slide
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
<span class="sr-only">Next page</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
6
web/src/lib/components/ui/textarea/index.js
Normal file
6
web/src/lib/components/ui/textarea/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from "./textarea.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
};
|
||||
29
web/src/lib/components/ui/textarea/textarea.svelte
Normal file
29
web/src/lib/components/ui/textarea/textarea.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { cn } from "$lib/types/utils.ts";
|
||||
let className = undefined;
|
||||
export let value = undefined;
|
||||
export { className as class };
|
||||
export let readonly = undefined;
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
class={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-lg focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{readonly}
|
||||
on:blur
|
||||
on:change
|
||||
on:click
|
||||
on:focus
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:mouseover
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:paste
|
||||
on:input
|
||||
{...$$restProps}
|
||||
></textarea>
|
||||
1
web/src/lib/content/.obsidian/app.json
vendored
Normal file
1
web/src/lib/content/.obsidian/app.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
web/src/lib/content/.obsidian/appearance.json
vendored
Normal file
1
web/src/lib/content/.obsidian/appearance.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
30
web/src/lib/content/.obsidian/core-plugins.json
vendored
Normal file
30
web/src/lib/content/.obsidian/core-plugins.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"canvas": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"properties": false,
|
||||
"page-preview": true,
|
||||
"daily-notes": true,
|
||||
"templates": true,
|
||||
"note-composer": true,
|
||||
"command-palette": true,
|
||||
"slash-command": false,
|
||||
"editor-status": true,
|
||||
"bookmarks": true,
|
||||
"markdown-importer": false,
|
||||
"zk-prefixer": false,
|
||||
"random-note": false,
|
||||
"outline": true,
|
||||
"word-count": true,
|
||||
"slides": false,
|
||||
"audio-recorder": false,
|
||||
"workspaces": false,
|
||||
"file-recovery": true,
|
||||
"publish": false,
|
||||
"sync": false
|
||||
}
|
||||
5
web/src/lib/content/.obsidian/templates.json
vendored
Normal file
5
web/src/lib/content/.obsidian/templates.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"folder": "templates",
|
||||
"dateFormat": "YYYY-MM-DD",
|
||||
"timeFormat": "HH:mm"
|
||||
}
|
||||
8
web/src/lib/content/.obsidian/types.json
vendored
Normal file
8
web/src/lib/content/.obsidian/types.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"types": {
|
||||
"aliases": "aliases",
|
||||
"cssclasses": "multitext",
|
||||
"tags": "tags",
|
||||
"updated": "datetime"
|
||||
}
|
||||
}
|
||||
173
web/src/lib/content/.obsidian/workspace.json
vendored
Normal file
173
web/src/lib/content/.obsidian/workspace.json
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "d2e57b203fabd791",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "bede7cd0fb75a7df",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "c588c8d12c5f7702",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "templates/{{title}}.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "{{title}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "a69ef8c1dea71399",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "99030135b6260693",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "bbeb4ea8d01ce855",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "afc5509c38fa5543",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "53c950f1571616a8",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "Bookmarks"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
},
|
||||
"right": {
|
||||
"id": "74142d853a5fb911",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "065cd0d2b52977db",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "398f4b2bc7fb48c1",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "posts/welcome.md",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Backlinks for welcome"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "655e694ad24637c7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "posts/welcome.md",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Outgoing links from welcome"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "eba769dfb90abcb3",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "Tags"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2bcc1385d707df56",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "posts/welcome.md"
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Outline of welcome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:Open quick switcher": false,
|
||||
"graph:Open graph view": false,
|
||||
"canvas:Create new canvas": false,
|
||||
"daily-notes:Open today's daily note": false,
|
||||
"templates:Insert template": false,
|
||||
"command-palette:Open command palette": false
|
||||
}
|
||||
},
|
||||
"active": "c588c8d12c5f7702",
|
||||
"lastOpenFiles": [
|
||||
"posts/welcome.md",
|
||||
"templates/{{title}}.md",
|
||||
"posts/SkeletonUI.md",
|
||||
"posts/getting-started.md",
|
||||
"posts/extract_wisdom.md"
|
||||
]
|
||||
}
|
||||
226
web/src/lib/content/posts/SkeletonUI.md
Executable file
226
web/src/lib/content/posts/SkeletonUI.md
Executable file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: SkeletonUI
|
||||
tags:
|
||||
- svelte
|
||||
- styling
|
||||
- skeletonui
|
||||
date: 2023-01-17
|
||||
---
|
||||
SkeletonUI is a comprehensive UI toolkit that integrates seamlessly with SvelteKit and Tailwind CSS, enabling developers to build adaptive and accessible web interfaces efficiently.
|
||||
|
||||
SkeletonUI offers a comprehensive suite of components to enhance your Svelte applications. Below is a categorized list of these components, presented in Svelte syntax:
|
||||
|
||||
```svelte
|
||||
<!-- Layout Components -->
|
||||
<AppShell />
|
||||
<AppBar />
|
||||
<Sidebar />
|
||||
<Footer />
|
||||
|
||||
<!-- Navigation Components -->
|
||||
<NavMenu />
|
||||
<Breadcrumbs />
|
||||
<Tabs />
|
||||
<Pagination />
|
||||
|
||||
<!-- Form Components -->
|
||||
<Button />
|
||||
<Input />
|
||||
<Select />
|
||||
<Textarea />
|
||||
<Checkbox />
|
||||
<Radio />
|
||||
<Switch />
|
||||
<Slider />
|
||||
<FileUpload />
|
||||
|
||||
<!-- Data Display Components -->
|
||||
<Card />
|
||||
<Avatar />
|
||||
<Badge />
|
||||
<Chip />
|
||||
<Divider />
|
||||
<Table />
|
||||
<List />
|
||||
<Accordion />
|
||||
<ProgressBar />
|
||||
<Rating />
|
||||
<Tag />
|
||||
|
||||
<!-- Feedback Components -->
|
||||
<Alert />
|
||||
<Modal />
|
||||
<Toast />
|
||||
<Popover />
|
||||
<Tooltip />
|
||||
|
||||
<!-- Media Components -->
|
||||
<Image />
|
||||
<Video />
|
||||
<Icon />
|
||||
|
||||
<!-- Utility Components -->
|
||||
<Spinner />
|
||||
<SkeletonLoader />
|
||||
<Placeholder />
|
||||
```
|
||||
|
||||
For detailed information on each component, including their properties and usage examples, please refer to the official SkeletonUI documentation.
|
||||
___
|
||||
Below is an expanded cheat sheet to assist you in leveraging SkeletonUI within your SvelteKit projects.
|
||||
|
||||
**1\. Installation**
|
||||
|
||||
To set up SkeletonUI in a new SvelteKit project, follow these steps:
|
||||
|
||||
- **Create a new SvelteKit project**:
|
||||
|
||||
```bash
|
||||
npx sv create my-skeleton-app
|
||||
cd my-skeleton-app
|
||||
npm install
|
||||
```
|
||||
- **Install SkeletonUI packages**:
|
||||
|
||||
```bash
|
||||
npm install -D @skeletonlabs/skeleton@next @skeletonlabs/skeleton-svelte@next
|
||||
```
|
||||
- **Configure Tailwind CSS**:
|
||||
|
||||
In your `tailwind.config.js` file, add the following:
|
||||
|
||||
```javascript
|
||||
import { skeleton, contentPath } from '@skeletonlabs/skeleton/plugin';
|
||||
import * as themes from '@skeletonlabs/skeleton/themes';
|
||||
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{html,js,svelte,ts}',
|
||||
contentPath(import.meta.url, 'svelte')
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
skeleton({
|
||||
themes: [themes.cerberus, themes.rose]
|
||||
})
|
||||
]
|
||||
};
|
||||
```
|
||||
- **Set the active theme**:
|
||||
|
||||
In your `src/app.html`, set the `data-theme` attribute on the `<body>` tag:
|
||||
|
||||
```html
|
||||
<body data-theme="cerberus">
|
||||
<!-- Your content -->
|
||||
</body>
|
||||
```
|
||||
|
||||
For detailed installation instructions, refer to the official SkeletonUI documentation.
|
||||
|
||||
**2\. Components**
|
||||
|
||||
SkeletonUI offers a variety of pre-designed components to accelerate your development process. Here's how to use some of them:
|
||||
|
||||
- **Button**:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Button } from '@skeletonlabs/skeleton-svelte';
|
||||
</script>
|
||||
|
||||
<Button on:click={handleClick}>Click Me</Button>
|
||||
```
|
||||
- **Card**:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Card } from '@skeletonlabs/skeleton-svelte';
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<h2>Card Title</h2>
|
||||
<p>Card content goes here.</p>
|
||||
</Card>
|
||||
```
|
||||
- **Form Input**:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Input } from '@skeletonlabs/skeleton-svelte';
|
||||
let inputValue = '';
|
||||
</script>
|
||||
|
||||
<Input bind:value={inputValue} placeholder="Enter text" />
|
||||
```
|
||||
|
||||
For a comprehensive list of components and their usage, consult the SkeletonUI components documentation.
|
||||
|
||||
**3\. Theming**
|
||||
|
||||
SkeletonUI's theming system allows for extensive customization:
|
||||
|
||||
- **Applying a Theme**:
|
||||
|
||||
Set the desired theme in your `tailwind.config.js` and `app.html` as shown in the installation steps above.
|
||||
- **Switching Themes Dynamically**:
|
||||
|
||||
To enable dynamic theme switching, you can modify the `data-theme` attribute programmatically:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
function switchTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={() => switchTheme('rose')}>Switch to Rose Theme</button>
|
||||
```
|
||||
- **Customizing Themes**:
|
||||
|
||||
You can create custom themes by defining your own color palettes and settings in the `tailwind.config.js` file.
|
||||
|
||||
For more information on theming, refer to the SkeletonUI theming guide.
|
||||
|
||||
**4\. Utilities**
|
||||
|
||||
SkeletonUI provides several utility functions and actions to enhance your SvelteKit application:
|
||||
|
||||
- **Table of Contents**:
|
||||
|
||||
Automatically generate a table of contents based on page headings:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { TableOfContents, tocCrawler } from '@skeletonlabs/skeleton-svelte';
|
||||
</script>
|
||||
|
||||
<div use:tocCrawler>
|
||||
<TableOfContents />
|
||||
<!-- Your content with headings -->
|
||||
</div>
|
||||
```
|
||||
- **Transitions and Animations**:
|
||||
|
||||
Utilize built-in transitions for smooth animations:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { fade } from '@skeletonlabs/skeleton-svelte';
|
||||
let visible = true;
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div transition:fade>
|
||||
Fading content
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
For a full list of utilities and their usage, explore the SkeletonUI utilities documentation.
|
||||
|
||||
This cheat sheet provides a foundational overview to help you start integrating SkeletonUI into your SvelteKit projects. For more detailed information and advanced features, please refer to the official SkeletonUI documentation.
|
||||
|
||||
https://www.skeleton.dev/docs/introduction
|
||||
135
web/src/lib/content/posts/Using-Markdown-in-Svelte.md
Executable file
135
web/src/lib/content/posts/Using-Markdown-in-Svelte.md
Executable file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Using Markdown in Svelte
|
||||
description: Learn how to use your markdown documents in Svelte Applications!
|
||||
date: 2023-12-22
|
||||
tags: [markdown, svelte,web-dev, documentation]
|
||||
---
|
||||
[Mdsvex](https://mdsvex.pngwn.io/docs#install-it)
|
||||
|
||||
Here are some examples illustrating how to use Mdsvex in a Svelte application:
|
||||
|
||||
**Example 1**: Basic Markdown with Svelte Component
|
||||
Create a file named example.svx:
|
||||
|
||||
markdown
|
||||
```markdown
|
||||
---
|
||||
title: "Interactive Markdown Example"
|
||||
---
|
||||
|
||||
<script>
|
||||
import Counter from '../components/Counter.svelte';
|
||||
</script>
|
||||
|
||||
# {title}
|
||||
|
||||
This is an example of combining Markdown with a Svelte component:
|
||||
|
||||
<Counter />
|
||||
```
|
||||
|
||||
In this example:
|
||||
- The frontmatter (--- sections) defines variables like title.
|
||||
- A Svelte component Counter is imported and used inside the Markdown.
|
||||
|
||||
**Example 2**: Custom Layouts with Mdsvex
|
||||
Assuming you have a layout component at src/lib/layouts/BlogLayout.svelte:
|
||||
|
||||
svelte
|
||||
```text
|
||||
<!-- BlogLayout.svelte -->
|
||||
<script>
|
||||
export let title;
|
||||
</script>
|
||||
|
||||
<div class="blog-post">
|
||||
<h1>{title}</h1>
|
||||
<slot />
|
||||
</div>
|
||||
```
|
||||
|
||||
Now, to use this layout in your Markdown:
|
||||
**markdown**
|
||||
```markdown
|
||||
---
|
||||
title: "My Favorite Layout"
|
||||
layout: "../lib/layouts/BlogLayout.svelte"
|
||||
---
|
||||
|
||||
## Markdown with Custom Layout
|
||||
|
||||
This Markdown file will be wrapped by the `BlogLayout`.
|
||||
```
|
||||
|
||||
**Example 3:** Using Frontmatter Variables in Markdown
|
||||
**markdown**
|
||||
```markdown
|
||||
---
|
||||
author: "John Doe"
|
||||
date: "2024-11-15"
|
||||
---
|
||||
|
||||
# Blog Post
|
||||
|
||||
By {author} on {date}
|
||||
|
||||
Here's some markdown content. You can reference frontmatter values directly in the body.
|
||||
```
|
||||
|
||||
**Example 4**: Interactive Elements in Markdown
|
||||
markdown
|
||||
```markdown
|
||||
---
|
||||
title: "Interactive Chart"
|
||||
---
|
||||
|
||||
<script>
|
||||
import { Chart } from '../components/Chart.svelte';
|
||||
</script>
|
||||
|
||||
# {title}
|
||||
|
||||
Below is an interactive chart:
|
||||
|
||||
<Chart />
|
||||
```
|
||||
|
||||
## Setting Up Mdsvex
|
||||
|
||||
To make these work, you need to configure your SvelteKit project:
|
||||
1. Install Mdsvex:
|
||||
```bash
|
||||
npm install -D mdsvex
|
||||
```
|
||||
|
||||
2. Configure SvelteKit:
|
||||
In your svelte.config.js:
|
||||
```javascript
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import { mdsvex } from 'mdsvex';
|
||||
|
||||
/** @type {import('mdsvex').MdsvexOptions} */
|
||||
const mdsvexOptions = {
|
||||
extensions: ['.svx'],
|
||||
};
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
extensions: ['.svelte', '.svx'],
|
||||
preprocess: [
|
||||
vitePreprocess(),
|
||||
mdsvex(mdsvexOptions),
|
||||
],
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
3. Create a Route for Markdown Files:
|
||||
Place your .svx files in the src/routes directory or subdirectories, and SvelteKit will automatically handle them as routes.
|
||||
|
||||
These examples show how you can integrate Mdsvex into your Svelte application, combining the simplicity of Markdown with the interactivity of Svelte components. Remember, any Svelte component you want to use within Markdown must be exported from a .svelte file and imported in your .svx file.
|
||||
66
web/src/lib/content/posts/extract_wisdom.md
Executable file
66
web/src/lib/content/posts/extract_wisdom.md
Executable file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Extract Wisdom
|
||||
date: 2024-01-01
|
||||
description:
|
||||
tags: [patterns, fabric]
|
||||
---
|
||||
|
||||
# IDENTITY and PURPOSE
|
||||
|
||||
You extract surprising, insightful, and interesting information from text content. You are interested in insights related to the purpose and meaning of life, human flourishing, the role of technology in the future of humanity, artificial intelligence and its affect on humans, memes, learning, reading, books, continuous improvement, and similar topics.
|
||||
|
||||
Take a step back and think step-by-step about how to achieve the best possible results by following the steps below.
|
||||
|
||||
# STEPS
|
||||
|
||||
- Extract a summary of the content in 25 words, including who is presenting and the content being discussed into a section called SUMMARY.
|
||||
|
||||
- Extract 20 to 50 of the most surprising, insightful, and/or interesting ideas from the input in a section called IDEAS:. If there are less than 50 then collect all of them. Make sure you extract at least 20.
|
||||
|
||||
- Extract 10 to 20 of the best insights from the input and from a combination of the raw input and the IDEAS above into a section called INSIGHTS. These INSIGHTS should be fewer, more refined, more insightful, and more abstracted versions of the best ideas in the content.
|
||||
|
||||
- Extract 15 to 30 of the most surprising, insightful, and/or interesting quotes from the input into a section called QUOTES:. Use the exact quote text from the input.
|
||||
|
||||
- Extract 15 to 30 of the most practical and useful personal habits of the speakers, or mentioned by the speakers, in the content into a section called HABITS. Examples include but aren't limited to: sleep schedule, reading habits, things they always do, things they always avoid, productivity tips, diet, exercise, etc.
|
||||
|
||||
- Extract 15 to 30 of the most surprising, insightful, and/or interesting valid facts about the greater world that were mentioned in the content into a section called FACTS:.
|
||||
|
||||
- Extract all mentions of writing, art, tools, projects and other sources of inspiration mentioned by the speakers into a section called REFERENCES. This should include any and all references to something that the speaker mentioned.
|
||||
|
||||
- Extract the most potent takeaway and recommendation into a section called ONE-SENTENCE TAKEAWAY. This should be a 15-word sentence that captures the most important essence of the content.
|
||||
|
||||
- Extract the 15 to 30 of the most surprising, insightful, and/or interesting recommendations that can be collected from the content into a section called RECOMMENDATIONS.
|
||||
|
||||
# OUTPUT INSTRUCTIONS
|
||||
|
||||
- Only output Markdown.
|
||||
|
||||
- Write the IDEAS bullets as exactly 15 words.
|
||||
|
||||
- Write the RECOMMENDATIONS bullets as exactly 15 words.
|
||||
|
||||
- Write the HABITS bullets as exactly 15 words.
|
||||
|
||||
- Write the FACTS bullets as exactly 15 words.
|
||||
|
||||
- Write the INSIGHTS bullets as exactly 15 words.
|
||||
|
||||
- Extract at least 25 IDEAS from the content.
|
||||
|
||||
- Extract at least 10 INSIGHTS from the content.
|
||||
|
||||
- Extract at least 20 items for the other output sections.
|
||||
|
||||
- Do not give warnings or notes; only output the requested sections.
|
||||
|
||||
- You use bulleted lists for output, not numbered lists.
|
||||
|
||||
- Do not repeat ideas, quotes, facts, or resources.
|
||||
|
||||
- Do not start items with the same opening words.
|
||||
|
||||
- Ensure you follow ALL these instructions when creating your output.
|
||||
|
||||
# INPUT
|
||||
|
||||
INPUT:
|
||||
60
web/src/lib/content/posts/getting-started.md
Executable file
60
web/src/lib/content/posts/getting-started.md
Executable file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Getting Started with SvelteKit
|
||||
date: 2024-11-01
|
||||
---
|
||||
|
||||
# Getting Started with SvelteKit
|
||||
|
||||
SvelteKit is a framework for building web applications of all sizes, with a beautiful development experience and flexible filesystem-based routing.
|
||||
|
||||
## Why SvelteKit?
|
||||
|
||||
- Zero-config setup
|
||||
- Filesystem-based routing
|
||||
- Server-side rendering
|
||||
- Hot module replacement
|
||||
|
||||
```bash
|
||||
npx sv create my-app
|
||||
cd my-app
|
||||
npm install
|
||||
```
|
||||
|
||||
**Install SkeletonUI**
|
||||
|
||||
```bash
|
||||
npm i -D @skeletonlabs/skeleton@next @skeletonlabs/skeleton-svelte@next
|
||||
```
|
||||
|
||||
**Configure Tailwind CSS**
|
||||
|
||||
```tailwind.config
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
import { skeleton, contentPath } from '@skeletonlabs/skeleton/plugin';
|
||||
import * as themes from '@skeletonlabs/skeleton/themes';
|
||||
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{html,js,svelte,ts}',
|
||||
contentPath(import.meta.url, 'svelte')
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
skeleton({
|
||||
// NOTE: each theme included will be added to your CSS bundle
|
||||
themes: [ themes.cerberus, themes.rose ]
|
||||
})
|
||||
]
|
||||
} satisfies Config
|
||||
```
|
||||
|
||||
**Start the dev server**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Read more at https://svelte.dev, https://next.skeleton.dev/docs/get-started/installation/sveltekit, and https://www.skeleton.dev/docs/introduction
|
||||
18
web/src/lib/content/posts/welcome.md
Executable file
18
web/src/lib/content/posts/welcome.md
Executable file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Welcome to Your Blog
|
||||
description: First post on my new SvelteKit blog
|
||||
date: 2024-01-17
|
||||
tags: [welcome, blog]
|
||||
---
|
||||
This is the first post of your new blog, powered by [SvelteKit](/posts/getting-started), [Obsidian](/obsidian), and [Fabric](/about). I'm excited to share this project with you, and I hope you find it useful for your own writing and experiences.
|
||||
|
||||
This part of the application is edited in <a href="http://localhost:5173/obsidian" name="Obsidian">Obsidian</a>.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- Updates on Incorporating Fabric into your workflow
|
||||
- How to use Obsidian to manage you notes and workflows
|
||||
- How to use Fabric and Obsidian to write and publish
|
||||
- More ways to use Obsidian and Fabric together!
|
||||
|
||||
Stay tuned for more content!
|
||||
7
web/src/lib/content/templates/{{title}}.md
Normal file
7
web/src/lib/content/templates/{{title}}.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Your Title Here
|
||||
date:
|
||||
description: Post description
|
||||
updated:
|
||||
---
|
||||
{{Content}}
|
||||
BIN
web/src/lib/images/fabric-logo.gif
Normal file
BIN
web/src/lib/images/fabric-logo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 MiB |
BIN
web/src/lib/images/fabric-logo.png
Normal file
BIN
web/src/lib/images/fabric-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 MiB |
BIN
web/src/lib/images/johnconnor.png
Normal file
BIN
web/src/lib/images/johnconnor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
web/src/lib/images/obsidian-logo.png
Executable file
BIN
web/src/lib/images/obsidian-logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
50
web/src/lib/layouts/post.svelte
Normal file
50
web/src/lib/layouts/post.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { formatDistance } from 'date-fns';
|
||||
import TagList from '$components/ui/tag-list/TagList.svelte';
|
||||
|
||||
/** @type {string} */
|
||||
export let title;
|
||||
/** @type {string} */
|
||||
export let date;
|
||||
/** @type {string} */
|
||||
export let description;
|
||||
/** @type {string} */
|
||||
export let tags = [];
|
||||
/** @type {string}*/
|
||||
export let updated;
|
||||
/** @type {string}**/
|
||||
export let reference;
|
||||
</script>
|
||||
|
||||
<article class="prose prose-slate mx-auto max-w-3xl dark:prose-invert py-12">
|
||||
<header class="mb-8 not-prose">
|
||||
{#if title}
|
||||
<h1 class="mb-2 text-4xl font-bold">{title}</h1>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mb-4 text-lg text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
{#if date}
|
||||
<div class="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<time datetime={date}>{formatDistance(new Date(date), new Date(), { addSuffix: true })}</time>
|
||||
|
||||
{#if tags?.length}
|
||||
<span class="text-xs">•</span>
|
||||
<TagList {tags} className="flex-1" />
|
||||
{/if}
|
||||
{#if updated}
|
||||
<span class="text-xs">•</span>
|
||||
<time datetime={updated}>Updated {formatDistance(new Date(updated), new Date(), { addSuffix: true })}</time>
|
||||
{/if}
|
||||
{#if reference}
|
||||
<span class="text-xs">•</span>
|
||||
<a href={reference}>Reference</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="mt-8">
|
||||
<slot />
|
||||
</div>
|
||||
</article>
|
||||
24
web/src/lib/posts/index.ts
Normal file
24
web/src/lib/posts/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { compile } from 'mdsvex';
|
||||
|
||||
export interface Post {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
content?: any;
|
||||
}
|
||||
|
||||
const modules = import.meta.glob('../content/posts/*.md' + '../../routes/**/*.md', { eager: true });
|
||||
|
||||
export const posts: Post[] = Object.entries(modules).map(([path, module]: [string, any]) => {
|
||||
const slug = path.split('/').pop()?.replace('.md', '') || '';
|
||||
return {
|
||||
slug,
|
||||
title: module.metadata?.title || slug,
|
||||
date: module.metadata?.date || new Date().toISOString().split('T')[0],
|
||||
content: module.default
|
||||
};
|
||||
});
|
||||
|
||||
export async function getPost(slug: string) {
|
||||
return posts.find(p => p.slug === slug) || null;
|
||||
}
|
||||
38
web/src/lib/services/transcriptService.ts
Normal file
38
web/src/lib/services/transcriptService.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { YoutubeTranscript } from 'youtube-transcript';
|
||||
|
||||
export interface TranscriptResponse {
|
||||
transcript: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export async function getTranscript(url: string): Promise<TranscriptResponse> {
|
||||
try {
|
||||
const videoId = extractVideoId(url);
|
||||
if (!videoId) {
|
||||
throw new Error('Invalid YouTube URL');
|
||||
}
|
||||
|
||||
const transcriptItems = await YoutubeTranscript.fetchTranscript(videoId);
|
||||
const transcript = transcriptItems
|
||||
.map(item => item.text)
|
||||
.join(' ');
|
||||
|
||||
const transcriptTitle = transcriptItems
|
||||
.map(item => item.text)
|
||||
.join('');
|
||||
|
||||
// TODO: Add title fetching
|
||||
return {
|
||||
transcript,
|
||||
title: videoId // Just returning the video ID as title
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Transcript fetch error:', error);
|
||||
throw new Error('Failed to fetch transcript');
|
||||
}
|
||||
}
|
||||
|
||||
function extractVideoId(url: string): string | null {
|
||||
const match = url.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
129
web/src/lib/store/chat.ts
Normal file
129
web/src/lib/store/chat.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import type { ChatRequest, StreamResponse, ChatState, Message } from '$lib/types/interfaces/chat-interface';
|
||||
import { chatApi } from '$lib/types/chat/chat';
|
||||
import { modelConfig } from './model-config';
|
||||
import { systemPrompt } from '$lib/types/chat/patterns';
|
||||
|
||||
export const currentSession = writable<string | null>(null);
|
||||
export const chatState = writable<ChatState>({
|
||||
messages: [],
|
||||
isStreaming: false
|
||||
});
|
||||
|
||||
export const setSession = (sessionName: string | null) => {
|
||||
currentSession.set(sessionName);
|
||||
if (!sessionName) {
|
||||
clearMessages();
|
||||
}
|
||||
};
|
||||
|
||||
export const clearMessages = () => {
|
||||
chatState.update(state => ({ ...state, messages: [] }));
|
||||
};
|
||||
|
||||
export const revertLastMessage = () => {
|
||||
chatState.update(state => ({
|
||||
...state,
|
||||
messages: state.messages.slice(0, -1)
|
||||
}));
|
||||
};
|
||||
|
||||
export async function sendMessage(userInput: string, systemPromptText?: string) {
|
||||
// Guard against streaming state
|
||||
const currentState = get(chatState);
|
||||
if (currentState.isStreaming) {
|
||||
console.log('Message submission blocked - already streaming');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update chat state
|
||||
chatState.update((state) => ({
|
||||
...state,
|
||||
messages: [...state.messages, { role: 'user', content: userInput }],
|
||||
isStreaming: true
|
||||
}));
|
||||
|
||||
try {
|
||||
const config = get(modelConfig);
|
||||
const sessionName = get(currentSession);
|
||||
|
||||
const request: ChatRequest = {
|
||||
prompts: [{
|
||||
userInput: userInput,
|
||||
systemPrompt: systemPromptText || get(systemPrompt),
|
||||
model: Array.isArray(config.model) ? config.model.join(',') : config.model,
|
||||
vendor: '',
|
||||
patternName: '',
|
||||
}],
|
||||
temperature: config.temperature,
|
||||
top_p: config.top_p,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0
|
||||
};
|
||||
|
||||
const stream = await chatApi.streamChat(request);
|
||||
const reader = stream.getReader();
|
||||
|
||||
let assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: ''
|
||||
};
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
while (!isCancelled) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Check if we're still streaming before processing
|
||||
const currentState = get(chatState);
|
||||
if (!currentState.isStreaming) {
|
||||
isCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const response = value as StreamResponse;
|
||||
switch (response.type) {
|
||||
case 'content':
|
||||
assistantMessage.content += response.content += `\n`;
|
||||
chatState.update(state => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
{...assistantMessage}
|
||||
]
|
||||
}));
|
||||
break;
|
||||
case 'error':
|
||||
throw new Error(response.content);
|
||||
case 'complete':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
throw new Error('Stream cancelled');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
// Only add error message if still streaming
|
||||
const currentState = get(chatState);
|
||||
if (currentState.isStreaming) {
|
||||
chatState.update(state => ({
|
||||
...state,
|
||||
messages: [...state.messages, {
|
||||
role: 'assistant',
|
||||
content: `Error: ${error instanceof Error ? error.message : 'Unknown error occurred'}`
|
||||
}]
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
chatState.update(state => ({
|
||||
...state,
|
||||
isStreaming: false
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export type { StreamResponse };
|
||||
58
web/src/lib/store/model-config.ts
Normal file
58
web/src/lib/store/model-config.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { modelsApi } from '$lib/types/chat/models';
|
||||
import { configApi } from '$lib/types/chat/config';
|
||||
import type { VendorModel } from '$lib/types/interfaces/model-interface';
|
||||
import type { ModelConfig } from '$lib/types/interfaces/model-interface';
|
||||
|
||||
export const modelConfig = writable<ModelConfig>({
|
||||
model: [],
|
||||
temperature: 0.7,
|
||||
maxLength: 2000,
|
||||
top_p: 0.9,
|
||||
frequency: 1
|
||||
});
|
||||
|
||||
export const availableModels = writable<VendorModel[]>([]);
|
||||
|
||||
// Initialize available models
|
||||
export async function loadAvailableModels() {
|
||||
try {
|
||||
const models = await modelsApi.getAvailable();
|
||||
console.log('Load models:', models);
|
||||
const uniqueModels = [...new Map(models.map(model => [model.name, model])).values()];
|
||||
availableModels.set(uniqueModels);
|
||||
} catch (error) {
|
||||
console.error('Client failed to load available models:', error);
|
||||
availableModels.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize config
|
||||
export async function initializeConfig() {
|
||||
try {
|
||||
const config = await configApi.get();
|
||||
const safeConfig: ModelConfig = {
|
||||
...config,
|
||||
model: Array.isArray(config.model) ? config.model :
|
||||
typeof config.model === 'string' ? (config.model as string).split(',') : []
|
||||
};
|
||||
modelConfig.set(safeConfig);
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/* modelConfig.subscribe(async (config) => {
|
||||
try {
|
||||
const configRecord: Record<string, string> = {
|
||||
model: config.model.toString(),
|
||||
temperature: config.temperature.toString(),
|
||||
maxLength: config.maxLength.toString(),
|
||||
top_p: config.top_p.toString(),
|
||||
frequency: config.frequency.toString()
|
||||
};
|
||||
// await configApi.update(configRecord);
|
||||
} catch (error) {
|
||||
console.error('Failed to update config:', error);
|
||||
}
|
||||
}); */
|
||||
25
web/src/lib/store/theme.ts
Normal file
25
web/src/lib/store/theme.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
function createThemeStore() {
|
||||
const { subscribe, set, update } = writable<'light' | 'dark'>('dark');
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
toggleTheme: () => update(theme => {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.toggle('dark', newTheme === 'dark');
|
||||
}
|
||||
return newTheme;
|
||||
}),
|
||||
setTheme: (theme: 'light' | 'dark') => {
|
||||
set(theme);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const theme = createThemeStore();
|
||||
export const toggleTheme = theme.toggleTheme;
|
||||
131
web/src/lib/types/chat/base.ts
Normal file
131
web/src/lib/types/chat/base.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// import type { ModelConfig } from '$lib/types/model-types';
|
||||
import type { StorageEntity } from '$lib/types/interfaces/storage-interface';
|
||||
|
||||
interface APIErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface APIResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Define and export the base api object
|
||||
export const api = {
|
||||
async fetch<T>(endpoint: string, options: RequestInit = {}): Promise<APIResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json() as APIErrorResponse;
|
||||
return { error: errorData.error || response.statusText };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
get: <T>(fetch: typeof window.fetch, endpoint: string) => api.fetch<T>(endpoint),
|
||||
|
||||
post: <T>(fetch: typeof window.fetch, endpoint: string, data: unknown) =>
|
||||
api.fetch<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
put: <T>(fetch: typeof window.fetch, endpoint: string, data?: unknown) =>
|
||||
api.fetch<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
}),
|
||||
|
||||
delete: <T>(fetch: typeof window.fetch, endpoint: string) =>
|
||||
api.fetch<T>(endpoint, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
stream: async function*(fetch: typeof window.fetch, endpoint: string, data: unknown): AsyncGenerator<string> {
|
||||
const response = await fetch(`/api${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Response body is null');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
yield decoder.decode(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function createStorageAPI<T extends StorageEntity>(entityType: string) {
|
||||
return {
|
||||
// Get a specific entity by name
|
||||
async get(name: string): Promise<T> {
|
||||
const response = await api.fetch<T>(`/api${entityType}/${name}`);
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data as T;
|
||||
},
|
||||
|
||||
// Get all entity names
|
||||
async getNames(): Promise<string[]> {
|
||||
const response = await api.fetch<string[]>(`/api${entityType}/names`);
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data || [];
|
||||
},
|
||||
|
||||
// Delete an entity
|
||||
async delete(name: string): Promise<void> {
|
||||
const response = await api.fetch(`/api${entityType}/${name}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.error) throw new Error(response.error);
|
||||
},
|
||||
|
||||
// Check if an entity exists
|
||||
async exists(name: string): Promise<boolean> {
|
||||
const response = await api.fetch<boolean>(`/api${entityType}/exists/${name}`);
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data || false;
|
||||
},
|
||||
|
||||
// Rename an entity
|
||||
async rename(oldName: string, newName: string): Promise<void> {
|
||||
const response = await api.fetch(`/api${entityType}/rename/${oldName}/${newName}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
if (response.error) throw new Error(response.error);
|
||||
},
|
||||
|
||||
// Save an entity
|
||||
async save(name: string, content: string | object): Promise<void> {
|
||||
const body = typeof content === 'string' ? content : JSON.stringify(content);
|
||||
const response = await api.fetch(`/api${entityType}/${name}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
if (response.error) throw new Error(response.error);
|
||||
},
|
||||
};
|
||||
}
|
||||
59
web/src/lib/types/chat/chat.ts
Normal file
59
web/src/lib/types/chat/chat.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// import { api } from '$lib/types/base';
|
||||
import type { ChatRequest, StreamResponse } from '$lib/types/interfaces/chat-interface';
|
||||
|
||||
// Create a chat API client
|
||||
export const chatApi = {
|
||||
async streamChat(request: ChatRequest): Promise<ReadableStream<StreamResponse>> {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Response body is null');
|
||||
|
||||
let buffer = '';
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += new TextDecoder().decode(value);
|
||||
const messages = buffer
|
||||
.split('\n\n')
|
||||
.filter(msg => msg.startsWith('data: '));
|
||||
|
||||
// Process complete messages
|
||||
if (messages.length > 1) {
|
||||
// Keep the last (potentially incomplete) chunk in the buffer
|
||||
buffer = messages.pop() || '';
|
||||
|
||||
for (const msg of messages) {
|
||||
controller.enqueue(JSON.parse(msg.slice(6)) as StreamResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
} finally {
|
||||
// Process any remaining complete messages in the buffer
|
||||
if (buffer.startsWith('data: ')) {
|
||||
controller.enqueue(JSON.parse(buffer.slice(6)) as StreamResponse);
|
||||
}
|
||||
controller.close();
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user