playing with vdom/claude

This commit is contained in:
sawka 2024-10-31 10:24:24 -07:00
parent 77fbd324c9
commit df61e2108a
9 changed files with 1430 additions and 1 deletions

308
cmd/wsh/cmd/wshcmd-aww.go Normal file
View File

@ -0,0 +1,308 @@
package cmd
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"github.com/nfnt/resize"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
)
type RedditListing struct {
Data struct {
Children []struct {
Data struct {
Title string `json:"title"`
URL string `json:"url"`
Permalink string `json:"permalink"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
var postPosition int
var awwCmd = &cobra.Command{
Use: "aww [position]",
Short: "show today's top r/aww image",
RunE: awwRun,
}
func init() {
awwCmd.Flags().IntVarP(&postPosition, "position", "p", 0, "position of post (0-based)")
rootCmd.AddCommand(awwCmd)
}
func AwwStyleTag(ctx context.Context, props map[string]any) any {
return vdom.Bind(`
<style>
.aww-container {
padding: 20px;
max-width: 800px;
margin: 0 auto;
text-align: center;
font-family: system-ui, -apple-system, sans-serif;
}
.title {
font-size: 24px;
color: #2c3e50;
margin-bottom: 20px;
line-height: 1.4;
}
.image-container {
margin: 20px 0;
display: inline-block;
max-width: 100%;
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.image-container img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.link {
display: inline-block;
margin-top: 15px;
color: #2980b9;
text-decoration: none;
font-size: 16px;
}
.link:hover {
text-decoration: underline;
}
.loading {
padding: 40px;
text-align: center;
color: #666;
font-size: 18px;
}
.error {
color: #e74c3c;
padding: 20px;
text-align: center;
font-size: 18px;
background: #fdf0ed;
border-radius: 8px;
margin: 20px 0;
}
</style>
`, nil)
}
func AwwContentTag(ctx context.Context, props map[string]any) any {
isLoading := GlobalVDomClient.GetAtomVal("isLoading").(bool)
errorMsg := GlobalVDomClient.GetAtomVal("errorMsg").(string)
imageData := GlobalVDomClient.GetAtomVal("imageData")
title := GlobalVDomClient.GetAtomVal("title")
postURL := GlobalVDomClient.GetAtomVal("postURL")
if isLoading {
return vdom.Bind(`
<div className="aww-container">
<div className="loading">Finding today's cutest image...</div>
</div>
`, nil)
}
if errorMsg != "" {
return vdom.Bind(`
<div className="aww-container">
<div className="error"><bindparam key="error"/></div>
</div>
`, map[string]any{
"error": errorMsg,
})
}
return vdom.Bind(`
<div className="aww-container">
<div className="title"><bindparam key="title"/></div>
<a href="#param:postURL" className="image-container">
<img src="#param:imageData" alt="Cute animal of the day"/>
</a>
<a href="#param:postURL" className="link">View on Reddit</a>
</div>
`, map[string]any{
"title": title,
"imageData": imageData,
"postURL": postURL,
})
}
// downloadImage downloads an image from URL and returns its bytes
func downloadImage(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad status: %s", resp.Status)
}
return io.ReadAll(resp.Body)
}
// resizeImage resizes the image to be under 64KB
func resizeImage(imgBytes []byte) (string, error) {
// Decode image
img, _, err := image.Decode(strings.NewReader(string(imgBytes)))
if err != nil {
return "", err
}
// Start with original size
width := uint(img.Bounds().Dx())
height := uint(img.Bounds().Dy())
scaleFactor := 1.0
// Keep trying until we get under 64KB
for {
// Resize image
newWidth := uint(float64(width) * scaleFactor)
newHeight := uint(float64(height) * scaleFactor)
resized := resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
// Encode to JPEG with quality 85
var buf strings.Builder
jpeg.Encode(base64.NewEncoder(base64.StdEncoding, &buf), resized, &jpeg.Options{Quality: 85})
encoded := buf.String()
// Check if we're under 64KB
if len(encoded) < 64*1024 {
return fmt.Sprintf("data:image/jpeg;base64,%s", encoded), nil
}
// Reduce size by 10% and try again
scaleFactor *= 0.9
}
}
func fetchTopAwwPost(position int) (string, string, string, error) {
// Get a few extra posts in case some aren't images
log.Printf("Fetching top %d posts from r/aww, pos:%d\n", position+5, position)
limit := position + 5
resp, err := http.Get(fmt.Sprintf("https://www.reddit.com/r/aww/top.json?t=day&limit=%d", limit))
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", "", fmt.Errorf("bad status: %s", resp.Status)
}
var listing RedditListing
if err := json.NewDecoder(resp.Body).Decode(&listing); err != nil {
return "", "", "", err
}
log.Printf("got listings num:%d\n", len(listing.Data.Children))
if len(listing.Data.Children) <= position {
return "", "", "", fmt.Errorf("no post found at position %d", position)
}
post := listing.Data.Children[position].Data
return post.Title, post.URL, "https://reddit.com" + post.Permalink, nil
}
func awwRun(cmd *cobra.Command, args []string) error {
if postPosition < 0 {
return fmt.Errorf("position must be non-negative")
}
client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
if err != nil {
return err
}
GlobalVDomClient = client
// Register components
client.RegisterComponent("AwwStyleTag", AwwStyleTag)
client.RegisterComponent("AwwContentTag", AwwContentTag)
// Initialize state
client.SetAtomVal("isLoading", true)
client.SetAtomVal("errorMsg", "")
client.SetAtomVal("imageData", "")
client.SetAtomVal("title", "")
client.SetAtomVal("postURL", "")
// Set root element
client.SetRootElem(vdom.Bind(`
<div>
<AwwStyleTag/>
<AwwContentTag/>
</div>
`, nil))
// Create VDOM context
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: true})
if err != nil {
return err
}
// Start fetching the image
go func() {
// Fetch top post
title, imageURL, postURL, err := fetchTopAwwPost(postPosition)
if err != nil {
client.SetAtomVal("errorMsg", fmt.Sprintf("Error fetching post: %v", err))
client.SetAtomVal("isLoading", false)
client.SendAsyncInitiation()
return
}
// Download image
imgBytes, err := downloadImage(imageURL)
if err != nil {
client.SetAtomVal("errorMsg", fmt.Sprintf("Error downloading image: %v", err))
client.SetAtomVal("isLoading", false)
client.SendAsyncInitiation()
return
}
// Resize and encode image
base64Image, err := resizeImage(imgBytes)
if err != nil {
client.SetAtomVal("errorMsg", fmt.Sprintf("Error processing image: %v", err))
client.SetAtomVal("isLoading", false)
client.SendAsyncInitiation()
return
}
// Update state with results
client.SetAtomVal("title", title)
client.SetAtomVal("imageData", base64Image)
client.SetAtomVal("postURL", postURL)
client.SetAtomVal("isLoading", false)
client.SendAsyncInitiation()
}()
<-client.DoneCh
return nil
}

258
cmd/wsh/cmd/wshcmd-ls.go Normal file
View File

@ -0,0 +1,258 @@
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
)
var lsCmd = &cobra.Command{
Use: "ls [directory]",
Short: "list directory contents with details",
RunE: lsRun,
}
func init() {
rootCmd.AddCommand(lsCmd)
}
type FileInfo struct {
Name string
Size int64
Mode os.FileMode
ModTime time.Time
IsDir bool
Extension string
}
func LsStyleTag(ctx context.Context, props map[string]any) any {
return vdom.Bind(`
<style>
.ls-container {
padding: 20px;
font-family: monospace;
}
.ls-table {
width: 100%;
border-collapse: collapse;
}
.ls-header {
text-align: left;
padding: 10px;
background: #2c3e50;
color: white;
font-weight: bold;
}
.ls-row {
border-bottom: 1px solid #eee;
}
.ls-row:hover {
background: #f5f5f5;
}
.ls-cell {
padding: 8px 10px;
}
.dir-name {
color: #2980b9;
font-weight: bold;
}
.file-name {
color: #2c3e50;
}
.size-cell {
text-align: right;
font-family: monospace;
}
.time-cell {
white-space: nowrap;
}
.mode-cell {
font-family: monospace;
white-space: pre;
}
.error {
color: #e74c3c;
padding: 20px;
text-align: center;
font-size: 16px;
}
</style>
`, nil)
}
// formatSize converts bytes to human readable format
func formatSize(size int64) string {
if size < 1024 {
return fmt.Sprintf("%d B", size)
}
units := []string{"B", "KB", "MB", "GB", "TB"}
div := float64(1)
unitIndex := 0
for size/int64(div) >= 1024 && unitIndex < len(units)-1 {
div *= 1024
unitIndex++
}
return fmt.Sprintf("%.1f %s", float64(size)/div, units[unitIndex])
}
// formatMode formats file permissions in a Unix-like style
func formatMode(mode os.FileMode) string {
output := ""
if mode.IsDir() {
output += "d"
} else {
output += "-"
}
// User permissions
output += formatPerm(mode, 6)
// Group permissions
output += formatPerm(mode, 3)
// Others permissions
output += formatPerm(mode, 0)
return output
}
// formatPerm formats a permission triplet
func formatPerm(mode os.FileMode, shift uint) string {
output := ""
output += map[bool]string{true: "r", false: "-"}[(mode>>(shift+2))&1 == 1]
output += map[bool]string{true: "w", false: "-"}[(mode>>(shift+1))&1 == 1]
output += map[bool]string{true: "x", false: "-"}[(mode>>shift)&1 == 1]
return output
}
func LsContentTag(ctx context.Context, props map[string]any) any {
path := props["path"].(string)
entries, err := os.ReadDir(path)
if err != nil {
return vdom.Bind(`
<div className="error">Error reading directory: <bindparam key="error"/></div>
`, map[string]any{
"error": err.Error(),
})
}
// Convert to FileInfo and sort
files := make([]FileInfo, 0, len(entries))
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, FileInfo{
Name: info.Name(),
Size: info.Size(),
Mode: info.Mode(),
ModTime: info.ModTime(),
IsDir: info.IsDir(),
Extension: strings.ToLower(filepath.Ext(info.Name())),
})
}
// Sort: directories first, then by name
sort.Slice(files, func(i, j int) bool {
if files[i].IsDir != files[j].IsDir {
return files[i].IsDir
}
return files[i].Name < files[j].Name
})
template := `
<div className="ls-container">
<table className="ls-table">
<tr>
<th className="ls-header">Mode</th>
<th className="ls-header">Size</th>
<th className="ls-header">Modified</th>
<th className="ls-header">Name</th>
</tr>`
for _, file := range files {
nameClass := "file-name"
if file.IsDir {
nameClass = "dir-name"
}
template += fmt.Sprintf(`
<tr className="ls-row">
<td className="ls-cell mode-cell">%s</td>
<td className="ls-cell size-cell">%s</td>
<td className="ls-cell time-cell">%s</td>
<td className="ls-cell %s">%s</td>
</tr>`,
formatMode(file.Mode),
formatSize(file.Size),
file.ModTime.Format("Jan 02 15:04"),
nameClass,
file.Name)
}
template += `
</table>
</div>`
return vdom.Bind(template, nil)
}
func lsRun(cmd *cobra.Command, args []string) error {
path := "."
if len(args) > 0 {
path = args[0]
}
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
if err != nil {
return err
}
GlobalVDomClient = client
// Register components
client.RegisterComponent("LsStyleTag", LsStyleTag)
client.RegisterComponent("LsContentTag", LsContentTag)
// Set root element
client.SetRootElem(vdom.Bind(`
<div>
<LsStyleTag/>
<LsContentTag path="#param:path"/>
</div>
`, map[string]any{
"path": absPath,
}))
// Create VDOM context
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: true})
if err != nil {
return err
}
<-client.DoneCh
return nil
}

320
cmd/wsh/cmd/wshcmd-rhyme.go Normal file
View File

@ -0,0 +1,320 @@
package cmd
import (
"context"
"fmt"
"log"
"strings"
"github.com/sashabaranov/go-openai"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
)
const OPENAI_API_KEY = ""
var rhymeCmd = &cobra.Command{
Use: "rhyme [word]",
Short: "find rhyming words using ChatGPT",
RunE: rhymeRun,
}
func init() {
rootCmd.AddCommand(rhymeCmd)
}
type RhymeCategory struct {
Type string
Words []string
}
func RhymeStyleTag(ctx context.Context, props map[string]any) any {
return vdom.Bind(`
<style>
.rhyme-container {
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
}
.word-header {
font-size: 32px;
font-weight: bold;
color: #2c3e50;
margin-bottom: 20px;
text-align: center;
}
.loading {
text-align: center;
color: #666;
padding: 40px;
font-size: 18px;
}
.error {
color: #e74c3c;
padding: 20px;
text-align: center;
font-size: 18px;
background: #fdf0ed;
border-radius: 8px;
margin: 20px 0;
}
.category {
margin-bottom: 30px;
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.category-title {
font-size: 20px;
font-weight: 600;
color: #2980b9;
margin-bottom: 15px;
border-bottom: 2px solid #eee;
padding-bottom: 8px;
}
.word-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.word-item {
background: #e8f0fe;
padding: 8px 12px;
border-radius: 4px;
text-align: center;
transition: all 0.2s;
color: #2c3e50;
font-weight: 500;
border: 1px solid #d0e1fd;
}
.word-item:hover {
background: #d0e1fd;
transform: translateY(-1px);
}
.no-results {
text-align: center;
color: #666;
padding: 40px;
font-style: italic;
}
</style>
`, nil)
}
func RhymeContentTag(ctx context.Context, props map[string]any) any {
word := props["word"].(string)
isLoading := GlobalVDomClient.GetAtomVal("isLoading").(bool)
errorMsg := GlobalVDomClient.GetAtomVal("errorMsg").(string)
rhymeData := GlobalVDomClient.GetAtomVal("rhymeData")
if isLoading {
return vdom.Bind(`
<div className="rhyme-container">
<div className="word-header"><bindparam key="word"/></div>
<div className="loading">Finding rhymes...</div>
</div>
`, map[string]any{
"word": word,
})
}
if errorMsg != "" {
return vdom.Bind(`
<div className="rhyme-container">
<div className="word-header"><bindparam key="word"/></div>
<div className="error"><bindparam key="error"/></div>
</div>
`, map[string]any{
"word": word,
"error": errorMsg,
})
}
if rhymeData == nil {
return vdom.Bind(`
<div className="rhyme-container">
<div className="word-header"><bindparam key="word"/></div>
<div className="no-results">No rhymes found</div>
</div>
`, map[string]any{
"word": word,
})
}
categories := rhymeData.([]RhymeCategory)
if len(categories) == 0 {
return vdom.Bind(`
<div className="rhyme-container">
<div className="word-header"><bindparam key="word"/></div>
<div className="no-results">No rhymes found</div>
</div>
`, map[string]any{
"word": word,
})
}
template := `
<div className="rhyme-container">
<div className="word-header">Rhymes for "<bindparam key="word"/>"</div>`
for _, category := range categories {
template += fmt.Sprintf(`
<div className="category">
<div className="category-title">%s</div>
<div className="word-grid">`, category.Type)
for _, rhyme := range category.Words {
template += fmt.Sprintf(`
<div className="word-item">%s</div>`, rhyme)
}
template += `
</div>
</div>`
}
template += `</div>`
return vdom.Bind(template, map[string]any{
"word": word,
})
}
func findRhymes(client *openai.Client, word string) ([]RhymeCategory, error) {
prompt := fmt.Sprintf(`For the word "%s", provide rhyming words in these categories:
1. Perfect rhymes (same ending sound)
2. Near rhymes (similar ending sound)
3. Family rhymes (same word ending)
Format the response as a simple list with categories and words like this:
Perfect rhymes: word1, word2, word3
Near rhymes: word1, word2, word3
Family rhymes: word1, word2, word3
Only include words that actually exist in English.`, word)
resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT3Dot5Turbo,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
Temperature: 0.7,
},
)
if err != nil {
return nil, err
}
// Parse the response
response := resp.Choices[0].Message.Content
categories := []RhymeCategory{}
// Split into lines and parse each category
lines := strings.Split(response, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, ":")
if len(parts) != 2 {
continue
}
categoryType := strings.TrimSpace(parts[0])
wordList := strings.Split(strings.TrimSpace(parts[1]), ",")
// Clean up the words
words := make([]string, 0)
for _, word := range wordList {
word = strings.TrimSpace(word)
if word != "" {
words = append(words, word)
}
}
if len(words) > 0 {
categories = append(categories, RhymeCategory{
Type: categoryType,
Words: words,
})
}
}
return categories, nil
}
func rhymeRun(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return fmt.Errorf("please provide a word to find rhymes for")
}
word := args[0]
client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
if err != nil {
return err
}
GlobalVDomClient = client
// Register components
client.RegisterComponent("RhymeStyleTag", RhymeStyleTag)
client.RegisterComponent("RhymeContentTag", RhymeContentTag)
// Initialize state
client.SetAtomVal("isLoading", true)
client.SetAtomVal("errorMsg", "")
client.SetAtomVal("rhymeData", nil)
// Set root element
client.SetRootElem(vdom.Bind(`
<div>
<RhymeStyleTag/>
<RhymeContentTag word="#param:word"/>
</div>
`, map[string]any{
"word": word,
}))
// Create VDOM context
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: true})
if err != nil {
return err
}
// Start fetching rhymes
go func() {
openaiClient := openai.NewClient(OPENAI_API_KEY)
rhymes, err := findRhymes(openaiClient, word)
if err != nil {
log.Printf("Error finding rhymes: %v", err)
client.SetAtomVal("errorMsg", fmt.Sprintf("Error finding rhymes: %v", err))
} else {
client.SetAtomVal("rhymeData", rhymes)
}
client.SetAtomVal("isLoading", false)
client.SendAsyncInitiation()
}()
<-client.DoneCh
return nil
}

255
cmd/wsh/cmd/wshcmd-vdom.go Normal file
View File

@ -0,0 +1,255 @@
package cmd
import (
"context"
"log"
"math/rand"
"strconv"
"time"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
)
var moleCmd = &cobra.Command{
Use: "mole",
Hidden: true,
Short: "launch whack-a-mole game",
RunE: moleRun,
}
func init() {
rootCmd.AddCommand(moleCmd)
}
func MoleCellTag(ctx context.Context, props map[string]any) any {
index := props["index"].(int)
molePosition := GlobalVDomClient.GetAtomVal("molePosition").(int)
className := "cell"
showMole := false
if molePosition == index {
className += " mole"
showMole = true
}
template := `
<button className="#param:className" onClick="#param:clickHandler">`
if showMole {
// This is our SVG converted to base64
template += `<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSIzNSIgZmlsbD0iIzhCNDUxMyIvPjxlbGxpcHNlIGN4PSI1MCIgY3k9IjYwIiByeD0iMjUiIHJ5PSIyMCIgZmlsbD0iI0RFQjg4NyIvPjxlbGxpcHNlIGN4PSI1MCIgY3k9IjQ1IiByeD0iMTIiIHJ5PSI4IiBmaWxsPSIjNDYzMjIyIi8+PGNpcmNsZSBjeD0iMzUiIGN5PSIzNSIgcj0iNSIgZmlsbD0iYmxhY2siLz48Y2lyY2xlIGN4PSI2NSIgY3k9IjM1IiByPSI1IiBmaWxsPSJibGFjayIvPjxjaXJjbGUgY3g9IjMzIiBjeT0iMzMiIHI9IjIiIGZpbGw9IndoaXRlIi8+PGNpcmNsZSBjeD0iNjMiIGN5PSIzMyIgcj0iMiIgZmlsbD0id2hpdGUiLz48bGluZSB4MT0iMzUiIHkxPSI0NSIgeDI9IjIwIiB5Mj0iNDAiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMiIvPjxsaW5lIHgxPSIzNSIgeTE9IjQ4IiB4Mj0iMjAiIHkyPSI0OCIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIyIi8+PGxpbmUgeDE9IjM1IiB5MT0iNTEiIHgyPSIyMCIgeTI9IjU2IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjIiLz48bGluZSB4MT0iNjUiIHkxPSI0NSIgeDI9IjgwIiB5Mj0iNDAiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMiIvPjxsaW5lIHgxPSI2NSIgeTE9IjQ4IiB4Mj0iODAiIHkyPSI0OCIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIyIi8+PGxpbmUgeDE9IjY1IiB5MT0iNTEiIHgyPSI4MCIgeTI9IjU2IiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4=" width="60" height="60" alt="mole"></img>`
}
template += `</button>`
return vdom.Bind(template, map[string]any{
"className": className,
"clickHandler": props["onCellClick"],
})
}
func MoleStyleTag(ctx context.Context, props map[string]any) any {
return vdom.Bind(`
<style>
.game-container {
padding: 20px;
text-align: center;
}
.score {
font-size: 24px;
margin-bottom: 20px;
}
.grid {
display: inline-grid;
grid-template-columns: repeat(3, 100px);
grid-gap: 10px;
}
.cell {
width: 100px;
height: 100px;
border: 2px solid #333;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
padding: 0;
}
.cell.mole {
background-color: #8a6343;
}
.cell:hover {
opacity: 0.9;
transform: scale(0.95);
}
.cell img {
animation: pop-up 0.3s ease-out;
}
@keyframes pop-up {
0% { transform: translateY(50px) scale(0.5); opacity: 0; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
.start-button {
margin-top: 20px;
padding: 10px 20px;
font-size: 18px;
border-radius: 4px;
border: none;
background: #2196F3;
color: white;
cursor: pointer;
}
.start-button:hover {
background: #1976D2;
}
</style>
`, nil)
}
var moleScore int = 0
func MoleGameTag(ctx context.Context, props map[string]any) any {
makeHandleCellClick := func(index int) func() {
return func() {
currentScore := GlobalVDomClient.GetAtomVal("moleScore").(int)
molePosition := GlobalVDomClient.GetAtomVal("molePosition").(int)
isActive := GlobalVDomClient.GetAtomVal("moleGameActive").(bool)
log.Printf("cell clicked: %d (active:%v)\n", index, isActive)
if !isActive {
return
}
// Check if we clicked the right mole
if molePosition == index {
GlobalVDomClient.SetAtomVal("moleScore", currentScore+1)
moleScore++
// Move mole to new random position
newPosition := rand.Intn(9)
GlobalVDomClient.SetAtomVal("molePosition", newPosition)
}
}
}
toggleGame := func() {
isActive := GlobalVDomClient.GetAtomVal("moleGameActive").(bool)
if isActive {
GlobalVDomClient.SetAtomVal("moleGameActive", false)
GlobalVDomClient.SetAtomVal("moleScore", 0)
moleScore = 0
GlobalVDomClient.SetAtomVal("molePosition", -1)
} else {
GlobalVDomClient.SetAtomVal("moleGameActive", true)
GlobalVDomClient.SetAtomVal("molePosition", rand.Intn(9))
}
}
isActive := GlobalVDomClient.GetAtomVal("moleGameActive").(bool)
buttonText := "Start Game"
if isActive {
buttonText = "Stop Game"
}
return vdom.Bind(`
<div className="game-container">
<MoleStyleTag/>
<div className="score">Score: <bindparam key="moleScore"/></div>
<div className="grid">
<MoleCellTag index="#param:index0" onCellClick="#param:click0"/>
<MoleCellTag index="#param:index1" onCellClick="#param:click1"/>
<MoleCellTag index="#param:index2" onCellClick="#param:click2"/>
<MoleCellTag index="#param:index3" onCellClick="#param:click3"/>
<MoleCellTag index="#param:index4" onCellClick="#param:click4"/>
<MoleCellTag index="#param:index5" onCellClick="#param:click5"/>
<MoleCellTag index="#param:index6" onCellClick="#param:click6"/>
<MoleCellTag index="#param:index7" onCellClick="#param:click7"/>
<MoleCellTag index="#param:index8" onCellClick="#param:click8"/>
</div>
<button className="start-button" onClick="#param:toggleGame">
<bindparam key="buttonText"/>
</button>
</div>
`, map[string]any{
"toggleGame": toggleGame,
"buttonText": buttonText,
"index0": 0,
"index1": 1,
"index2": 2,
"index3": 3,
"index4": 4,
"index5": 5,
"index6": 6,
"index7": 7,
"index8": 8,
"click0": makeHandleCellClick(0),
"click1": makeHandleCellClick(1),
"click2": makeHandleCellClick(2),
"click3": makeHandleCellClick(3),
"click4": makeHandleCellClick(4),
"click5": makeHandleCellClick(5),
"click6": makeHandleCellClick(6),
"click7": makeHandleCellClick(7),
"click8": makeHandleCellClick(8),
"moleScore": strconv.Itoa(moleScore),
})
}
func moleRun(cmd *cobra.Command, args []string) error {
client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
if err != nil {
return err
}
GlobalVDomClient = client
// Initialize random seed
rand.Seed(time.Now().UnixNano())
// Register components
client.RegisterComponent("MoleStyleTag", MoleStyleTag)
client.RegisterComponent("MoleCellTag", MoleCellTag)
client.RegisterComponent("MoleGameTag", MoleGameTag)
// Initialize state
client.SetAtomVal("moleScore", 0)
client.SetAtomVal("molePosition", -1)
client.SetAtomVal("moleGameActive", false)
// Set root element
client.SetRootElem(vdom.Bind(`<MoleGameTag/>`, nil))
// Create VDOM context
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: true})
if err != nil {
return err
}
// Start game loop when active
go func() {
for {
time.Sleep(1 * time.Second)
if client.GetAtomVal("moleGameActive").(bool) {
newPos := rand.Intn(9)
client.SetAtomVal("molePosition", newPos)
log.Printf("new mole position: %d\n", newPos)
client.SendAsyncInitiation()
}
}
}()
<-client.DoneCh
return nil
}

282
cmd/wsh/cmd/wshcmd-word.go Normal file
View File

@ -0,0 +1,282 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
)
// DictionaryResponse represents the API response structure
type DictionaryResponse []struct {
Word string `json:"word"`
Phonetic string `json:"phonetic"`
Meanings []Meaning `json:"meanings"`
}
type Meaning struct {
PartOfSpeech string `json:"partOfSpeech"`
Definitions []struct {
Definition string `json:"definition"`
Example string `json:"example"`
Synonyms []string `json:"synonyms"`
} `json:"definitions"`
}
var wordCmd = &cobra.Command{
Use: "word",
Hidden: true,
Short: "show word of the day",
RunE: wordRun,
}
func init() {
rootCmd.AddCommand(wordCmd)
}
func WordStyleTag(ctx context.Context, props map[string]any) any {
return vdom.Bind(`
<style>
.word-container {
padding: 20px;
max-width: 600px;
margin: 0 auto;
font-family: system-ui, -apple-system, sans-serif;
}
.word-title {
font-size: 32px;
font-weight: bold;
color: #2c3e50;
margin-bottom: 10px;
}
.phonetic {
font-size: 18px;
color: #666;
margin-bottom: 20px;
font-style: italic;
}
.part-of-speech {
font-size: 20px;
color: #2980b9;
margin-top: 15px;
margin-bottom: 10px;
font-weight: 500;
}
.definition {
margin-bottom: 15px;
line-height: 1.5;
}
.example {
color: #666;
font-style: italic;
margin-top: 5px;
margin-left: 20px;
}
.error {
color: #e74c3c;
padding: 20px;
text-align: center;
font-size: 18px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
font-size: 18px;
}
.synonyms {
margin-top: 5px;
color: #666;
}
.synonym-tag {
display: inline-block;
background: #f0f0f0;
padding: 2px 8px;
border-radius: 4px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 14px;
}
</style>
`, nil)
}
func WordContentTag(ctx context.Context, props map[string]any) any {
wordData := GlobalVDomClient.GetAtomVal("wordData")
isLoading := GlobalVDomClient.GetAtomVal("isLoading").(bool)
errorMsg := GlobalVDomClient.GetAtomVal("errorMsg").(string)
if isLoading {
return vdom.Bind(`
<div className="loading">Loading word of the day...</div>
`, nil)
}
if errorMsg != "" {
return vdom.Bind(`
<div className="error"><bindparam key="error"/></div>
`, map[string]any{
"error": errorMsg,
})
}
if wordData == nil {
return vdom.Bind(`
<div className="loading">Initializing...</div>
`, nil)
}
response := wordData.(DictionaryResponse)
if len(response) == 0 {
return vdom.Bind(`
<div className="error">No word data found</div>
`, nil)
}
word := response[0]
template := `
<div className="word-container">
<div className="word-title"><bindparam key="word"/></div>
<div className="phonetic"><bindparam key="phonetic"/></div>
`
for _, meaning := range word.Meanings {
template += fmt.Sprintf(`
<div className="part-of-speech">%s</div>
`, meaning.PartOfSpeech)
for _, def := range meaning.Definitions {
template += fmt.Sprintf(`
<div className="definition">%s</div>
`, def.Definition)
if def.Example != "" {
template += fmt.Sprintf(`
<div className="example">"%s"</div>
`, def.Example)
}
if len(def.Synonyms) > 0 {
template += `<div className="synonyms">`
for _, syn := range def.Synonyms[:min(5, len(def.Synonyms))] {
template += fmt.Sprintf(`
<span className="synonym-tag">%s</span>
`, syn)
}
template += `</div>`
}
}
}
template += `</div>`
return vdom.Bind(template, map[string]any{
"word": word.Word,
"phonetic": word.Phonetic,
})
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
// getRandomWord returns a random word to look up
func getRandomWord() string {
words := []string{
"serendipity", "ephemeral", "mellifluous", "ineffable", "surreptitious",
"ethereal", "melancholy", "perspicacious", "sanguine", "vociferous",
"arduous", "mundane", "pragmatic", "resilient", "tenacious",
}
return words[time.Now().Unix()%int64(len(words))]
}
func fetchWordDefinition(word string) (DictionaryResponse, error) {
url := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", word)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result DictionaryResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
func wordRun(cmd *cobra.Command, args []string) error {
client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
if err != nil {
return err
}
GlobalVDomClient = client
// Register components
client.RegisterComponent("WordStyleTag", WordStyleTag)
client.RegisterComponent("WordContentTag", WordContentTag)
// Initialize state
client.SetAtomVal("wordData", nil)
client.SetAtomVal("isLoading", true)
client.SetAtomVal("errorMsg", "")
// Set root element
client.SetRootElem(vdom.Bind(`
<div>
<WordStyleTag/>
<WordContentTag/>
</div>
`, nil))
// Create VDOM context
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: true})
if err != nil {
return err
}
// Fetch word data
go func() {
word := getRandomWord()
data, err := fetchWordDefinition(word)
if err != nil {
client.SetAtomVal("errorMsg", fmt.Sprintf("Error fetching word: %v", err))
} else {
client.SetAtomVal("wordData", data)
}
client.SetAtomVal("isLoading", false)
client.SendAsyncInitiation()
}()
<-client.DoneCh
return nil
}

View File

@ -294,6 +294,9 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
return childrenComps;
}
props.key = "e-" + elem.waveid;
if (elem.tag == "img") {
childrenComps = null;
}
return React.createElement(elem.tag, props, childrenComps);
}

1
go.mod
View File

@ -16,6 +16,7 @@ require (
github.com/kevinburke/ssh_config v1.2.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/mitchellh/mapstructure v1.5.0
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/sashabaranov/go-openai v1.32.3
github.com/sawka/txwrap v0.2.0
github.com/shirou/gopsutil/v4 v4.24.9

2
go.sum
View File

@ -51,6 +51,8 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b h1:cLGKfKb1uk0hxI0Q8L83UAJPpeJ+gSpn3cCU/tjd3eg=
github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b/go.mod h1:KO+FcPtyLAiRC0hJwreJVvfwc7vnNz77UxBTIGHdPVk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View File

@ -65,7 +65,7 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom
impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData)
}
}
if feUpdate.Resync {
if feUpdate.Resync || true {
return impl.Client.fullRender()
}
return impl.Client.incrementalRender()