- Replace generic UI with dedicated Heroprompt web interface - Implement new Heroprompt-specific backend APIs - Develop client-side logic for file browsing and selection - Enhance workspace configuration and management capabilities - Remove deprecated generic UI modules and code
527 lines
12 KiB
V
527 lines
12 KiB
V
module heroprompt
|
||
|
||
import rand
|
||
import time
|
||
import os
|
||
import freeflowuniverse.herolib.core.pathlib
|
||
import freeflowuniverse.herolib.develop.codewalker
|
||
|
||
// Selection API
|
||
@[params]
|
||
pub struct AddDirParams {
|
||
pub mut:
|
||
path string @[required]
|
||
}
|
||
|
||
@[params]
|
||
pub struct AddFileParams {
|
||
pub mut:
|
||
path string @[required]
|
||
}
|
||
|
||
// add a directory to the selection (no recursion stored; recursion is done on-demand)
|
||
pub fn (mut wsp Workspace) add_dir(args AddDirParams) !HeropromptChild {
|
||
if args.path.len == 0 {
|
||
return error('the directory path is required')
|
||
}
|
||
|
||
if !os.exists(args.path) || !os.is_dir(args.path) {
|
||
return error('path is not an existing directory: ${args.path}')
|
||
}
|
||
|
||
abs_path := os.real_path(args.path)
|
||
name := os.base(abs_path)
|
||
|
||
for child in wsp.children {
|
||
if child.name == name {
|
||
return error('another directory with the same name already exists: ${name}')
|
||
}
|
||
}
|
||
|
||
mut ch := HeropromptChild{
|
||
path: pathlib.Path{
|
||
path: abs_path
|
||
cat: .dir
|
||
exist: .yes
|
||
}
|
||
name: name
|
||
}
|
||
wsp.children << ch
|
||
wsp.save()!
|
||
return ch
|
||
}
|
||
|
||
// add a file to the selection
|
||
pub fn (mut wsp Workspace) add_file(args AddFileParams) !HeropromptChild {
|
||
if args.path.len == 0 {
|
||
return error('The file path is required')
|
||
}
|
||
|
||
if !os.exists(args.path) || !os.is_file(args.path) {
|
||
return error('Path is not an existing file: ${args.path}')
|
||
}
|
||
|
||
abs_path := os.real_path(args.path)
|
||
name := os.base(abs_path)
|
||
|
||
for child in wsp.children {
|
||
if child.path.cat == .file && child.name == name {
|
||
return error('another file with the same name already exists: ${name}')
|
||
}
|
||
|
||
if child.path.cat == .dir && child.name == name {
|
||
return error('${name}: is a directory, cannot add file with same name')
|
||
}
|
||
}
|
||
|
||
content := os.read_file(abs_path) or { '' }
|
||
mut ch := HeropromptChild{
|
||
path: pathlib.Path{
|
||
path: abs_path
|
||
cat: .file
|
||
exist: .yes
|
||
}
|
||
name: name
|
||
content: content
|
||
}
|
||
|
||
wsp.children << ch
|
||
wsp.save()!
|
||
return ch
|
||
}
|
||
|
||
// Removal API
|
||
@[params]
|
||
pub struct RemoveParams {
|
||
pub mut:
|
||
path string
|
||
name string
|
||
}
|
||
|
||
// Remove a directory from the selection (by absolute path or name)
|
||
pub fn (mut wsp Workspace) remove_dir(args RemoveParams) ! {
|
||
if args.path.len == 0 && args.name.len == 0 {
|
||
return error('either path or name is required to remove a directory')
|
||
}
|
||
mut idxs := []int{}
|
||
for i, ch in wsp.children {
|
||
if ch.path.cat != .dir {
|
||
continue
|
||
}
|
||
if args.path.len > 0 && os.real_path(args.path) == ch.path.path {
|
||
idxs << i
|
||
continue
|
||
}
|
||
if args.name.len > 0 && args.name == ch.name {
|
||
idxs << i
|
||
}
|
||
}
|
||
if idxs.len == 0 {
|
||
return error('no matching directory found to remove')
|
||
}
|
||
// remove from end to start to keep indices valid
|
||
idxs.sort(a > b)
|
||
for i in idxs {
|
||
wsp.children.delete(i)
|
||
}
|
||
wsp.save()!
|
||
}
|
||
|
||
// Remove a file from the selection (by absolute path or name)
|
||
pub fn (mut wsp Workspace) remove_file(args RemoveParams) ! {
|
||
if args.path.len == 0 && args.name.len == 0 {
|
||
return error('either path or name is required to remove a file')
|
||
}
|
||
mut idxs := []int{}
|
||
for i, ch in wsp.children {
|
||
if ch.path.cat != .file {
|
||
continue
|
||
}
|
||
if args.path.len > 0 && os.real_path(args.path) == ch.path.path {
|
||
idxs << i
|
||
continue
|
||
}
|
||
if args.name.len > 0 && args.name == ch.name {
|
||
idxs << i
|
||
}
|
||
}
|
||
if idxs.len == 0 {
|
||
return error('no matching file found to remove')
|
||
}
|
||
idxs.sort(a > b)
|
||
for i in idxs {
|
||
wsp.children.delete(i)
|
||
}
|
||
wsp.save()!
|
||
}
|
||
|
||
// Delete this workspace from the store
|
||
pub fn (wsp &Workspace) delete_workspace() ! {
|
||
delete(name: wsp.name)!
|
||
}
|
||
|
||
// Update this workspace (name and/or base_path)
|
||
@[params]
|
||
pub struct UpdateParams {
|
||
pub mut:
|
||
name string
|
||
base_path string
|
||
}
|
||
|
||
pub fn (wsp &Workspace) update_workspace(args UpdateParams) !&Workspace {
|
||
mut updated := Workspace{
|
||
name: if args.name.len > 0 { args.name } else { wsp.name }
|
||
base_path: if args.base_path.len > 0 { args.base_path } else { wsp.base_path }
|
||
children: wsp.children
|
||
created: wsp.created
|
||
updated: time.now()
|
||
is_saved: true
|
||
}
|
||
// if name changed, delete old key first
|
||
if updated.name != wsp.name {
|
||
delete(name: wsp.name)!
|
||
}
|
||
set(updated)!
|
||
return get(name: updated.name)!
|
||
}
|
||
|
||
// @[params]
|
||
// pub struct UpdateParams {
|
||
// pub mut:
|
||
// name string
|
||
// base_path string
|
||
// // Update only the name and the base path for now
|
||
// }
|
||
|
||
// // Delete this workspace from the store
|
||
// pub fn (wsp &Workspace) update_workspace(args_ UpdateParams) ! {
|
||
// delete(name: wsp.name)!
|
||
// }
|
||
|
||
// List workspaces (wrapper over factory list)
|
||
pub fn list_workspaces() ![]&Workspace {
|
||
return list(fromdb: false)!
|
||
}
|
||
|
||
pub fn list_workspaces_fromdb() ![]&Workspace {
|
||
return list(fromdb: true)!
|
||
}
|
||
|
||
// List entries in a directory relative to this workspace base or absolute
|
||
@[params]
|
||
pub struct ListArgs {
|
||
pub mut:
|
||
path string // if empty, will use workspace.base_path
|
||
}
|
||
|
||
pub struct ListItem {
|
||
pub:
|
||
name string
|
||
typ string @[json: 'type']
|
||
}
|
||
|
||
pub fn (wsp &Workspace) list() ![]ListItem {
|
||
mut dir := wsp.base_path
|
||
if dir.len == 0 {
|
||
return error('workspace base_path not set')
|
||
}
|
||
|
||
if !os.is_abs_path(dir) {
|
||
dir = os.join_path(wsp.base_path, dir)
|
||
}
|
||
|
||
entries := os.ls(dir) or { return error('cannot list directory') }
|
||
mut out := []ListItem{}
|
||
for e in entries {
|
||
full := os.join_path(dir, e)
|
||
if os.is_dir(full) {
|
||
out << ListItem{
|
||
name: e
|
||
typ: 'directory'
|
||
}
|
||
} else if os.is_file(full) {
|
||
out << ListItem{
|
||
name: e
|
||
typ: 'file'
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Get the currently selected children (copy)
|
||
pub fn (wsp Workspace) selected_children() []HeropromptChild {
|
||
return wsp.children.clone()
|
||
}
|
||
|
||
// Build utilities
|
||
fn list_files_recursive(root string) []string {
|
||
mut out := []string{}
|
||
entries := os.ls(root) or { return out }
|
||
for e in entries {
|
||
fp := os.join_path(root, e)
|
||
if os.is_dir(fp) {
|
||
out << list_files_recursive(fp)
|
||
} else if os.is_file(fp) {
|
||
out << fp
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// build_file_content generates formatted content for all selected files (and all files under selected dirs)
|
||
fn (wsp Workspace) build_file_content() !string {
|
||
mut content := ''
|
||
// files selected directly
|
||
for ch in wsp.children {
|
||
if ch.path.cat == .file {
|
||
if content.len > 0 {
|
||
content += '\n\n'
|
||
}
|
||
content += '${ch.path.path}\n'
|
||
ext := get_file_extension(ch.name)
|
||
if ch.content.len == 0 {
|
||
// read on demand
|
||
ch_content := os.read_file(ch.path.path) or { '' }
|
||
if ch_content.len == 0 {
|
||
content += '(Empty file)\n'
|
||
} else {
|
||
content += '```' + ext + '\n' + ch_content + '\n```'
|
||
}
|
||
} else {
|
||
content += '```' + ext + '\n' + ch.content + '\n```'
|
||
}
|
||
}
|
||
}
|
||
// files under selected directories, using CodeWalker for filtered traversal
|
||
for ch in wsp.children {
|
||
if ch.path.cat == .dir {
|
||
mut cw := codewalker.new(codewalker.CodeWalkerArgs{})!
|
||
mut fm := cw.filemap_get(path: ch.path.path)!
|
||
for rel, fc in fm.content {
|
||
if content.len > 0 {
|
||
content += '\n\n'
|
||
}
|
||
abs := os.join_path(ch.path.path, rel)
|
||
content += abs + '\n'
|
||
ext := get_file_extension(os.base(abs))
|
||
if fc.len == 0 {
|
||
content += '(Empty file)\n'
|
||
} else {
|
||
content += '```' + ext + '\n' + fc + '\n```'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return content
|
||
}
|
||
|
||
// Minimal tree builder for selected directories only; marks files with *
|
||
fn build_file_tree_fs(roots []HeropromptChild, prefix string) string {
|
||
mut out := ''
|
||
for i, root in roots {
|
||
if root.path.cat != .dir {
|
||
continue
|
||
}
|
||
connector := if i == roots.len - 1 { '└── ' } else { '├── ' }
|
||
out += '${prefix}${connector}${root.name}\n'
|
||
child_prefix := if i == roots.len - 1 { prefix + ' ' } else { prefix + '│ ' }
|
||
// list children under root
|
||
entries := os.ls(root.path.path) or { []string{} }
|
||
// sort: dirs first then files
|
||
mut dirs := []string{}
|
||
mut files := []string{}
|
||
for e in entries {
|
||
fp := os.join_path(root.path.path, e)
|
||
if os.is_dir(fp) {
|
||
dirs << fp
|
||
} else if os.is_file(fp) {
|
||
files << fp
|
||
}
|
||
}
|
||
dirs.sort()
|
||
files.sort()
|
||
// files
|
||
for j, f in files {
|
||
file_connector := if j == files.len - 1 && dirs.len == 0 {
|
||
'└── '
|
||
} else {
|
||
'├── '
|
||
}
|
||
out += '${child_prefix}${file_connector}${os.base(f)} *\n'
|
||
}
|
||
// subdirectories
|
||
for j, d in dirs {
|
||
sub_connector := if j == dirs.len - 1 { '└── ' } else { '├── ' }
|
||
out += '${child_prefix}${sub_connector}${os.base(d)}\n'
|
||
sub_prefix := if j == dirs.len - 1 {
|
||
child_prefix + ' '
|
||
} else {
|
||
child_prefix + '│ '
|
||
}
|
||
out += build_file_tree_fs([
|
||
HeropromptChild{
|
||
path: pathlib.Path{
|
||
path: d
|
||
cat: .dir
|
||
exist: .yes
|
||
}
|
||
name: os.base(d)
|
||
},
|
||
], sub_prefix)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
pub struct HeropromptTmpPrompt {
|
||
pub mut:
|
||
user_instructions string
|
||
file_map string
|
||
file_contents string
|
||
}
|
||
|
||
fn (wsp Workspace) build_user_instructions(text string) string {
|
||
return text
|
||
}
|
||
|
||
// build_file_map creates a complete file map with base path and metadata
|
||
fn (wsp Workspace) build_file_map() string {
|
||
mut file_map := ''
|
||
// roots are selected directories
|
||
mut roots := []HeropromptChild{}
|
||
mut files_only := []HeropromptChild{}
|
||
for ch in wsp.children {
|
||
if ch.path.cat == .dir {
|
||
roots << ch
|
||
} else if ch.path.cat == .file {
|
||
files_only << ch
|
||
}
|
||
}
|
||
if roots.len > 0 {
|
||
base_path := roots[0].path.path
|
||
parent_path := if base_path.contains('/') {
|
||
base_path.split('/')[..base_path.split('/').len - 1].join('/')
|
||
} else {
|
||
base_path
|
||
}
|
||
// metadata
|
||
mut total_files := 0
|
||
mut total_content_length := 0
|
||
mut file_extensions := map[string]int{}
|
||
// files under dirs
|
||
for r in roots {
|
||
for f in list_files_recursive(r.path.path) {
|
||
total_files++
|
||
ext := get_file_extension(os.base(f))
|
||
if ext.len > 0 {
|
||
file_extensions[ext] = file_extensions[ext] + 1
|
||
}
|
||
total_content_length += (os.read_file(f) or { '' }).len
|
||
}
|
||
}
|
||
// files only
|
||
for fo in files_only {
|
||
total_files++
|
||
ext := get_file_extension(fo.name)
|
||
if ext.len > 0 {
|
||
file_extensions[ext] = file_extensions[ext] + 1
|
||
}
|
||
total_content_length += fo.content.len
|
||
}
|
||
mut extensions_summary := ''
|
||
for ext, count in file_extensions {
|
||
if extensions_summary.len > 0 {
|
||
extensions_summary += ', '
|
||
}
|
||
extensions_summary += '${ext}(${count})'
|
||
}
|
||
file_map = '${parent_path}\n'
|
||
file_map += '# Selected Files: ${total_files} | Total Content: ${total_content_length} chars'
|
||
if extensions_summary.len > 0 {
|
||
file_map += ' | Extensions: ${extensions_summary}'
|
||
}
|
||
file_map += '\n\n'
|
||
file_map += build_file_tree_fs(roots, '')
|
||
// list standalone files as well
|
||
for fo in files_only {
|
||
file_map += fo.path.path + ' *\n'
|
||
}
|
||
}
|
||
return file_map
|
||
}
|
||
|
||
pub struct WorkspacePrompt {
|
||
pub mut:
|
||
text string
|
||
}
|
||
|
||
pub fn (wsp Workspace) prompt(args WorkspacePrompt) string {
|
||
user_instructions := wsp.build_user_instructions(args.text)
|
||
file_map := wsp.build_file_map()
|
||
file_contents := wsp.build_file_content() or { '(Error building file contents)' }
|
||
prompt := HeropromptTmpPrompt{
|
||
user_instructions: user_instructions
|
||
file_map: file_map
|
||
file_contents: file_contents
|
||
}
|
||
reprompt := $tmpl('./templates/prompt.template')
|
||
return reprompt
|
||
}
|
||
|
||
// Save the workspace
|
||
fn (wsp &Workspace) save() !&Workspace {
|
||
mut tmp := wsp
|
||
tmp.updated = time.now()
|
||
tmp.is_saved = true
|
||
set(tmp)!
|
||
return get(name: wsp.name)!
|
||
}
|
||
|
||
// Generate a random name for the workspace
|
||
fn generate_random_workspace_name() string {
|
||
adjectives := [
|
||
'brave',
|
||
'bright',
|
||
'clever',
|
||
'swift',
|
||
'noble',
|
||
'mighty',
|
||
'fearless',
|
||
'bold',
|
||
'wise',
|
||
'epic',
|
||
'valiant',
|
||
'fierce',
|
||
'legendary',
|
||
'heroic',
|
||
'dynamic',
|
||
]
|
||
nouns := [
|
||
'forge',
|
||
'script',
|
||
'ocean',
|
||
'phoenix',
|
||
'atlas',
|
||
'quest',
|
||
'shield',
|
||
'dragon',
|
||
'code',
|
||
'summit',
|
||
'path',
|
||
'realm',
|
||
'spark',
|
||
'anvil',
|
||
'saga',
|
||
]
|
||
|
||
// Seed randomness with time
|
||
rand.seed([u32(time.now().unix()), u32(time.now().nanosecond)])
|
||
|
||
adj := adjectives[rand.intn(adjectives.len) or { 0 }]
|
||
noun := nouns[rand.intn(nouns.len) or { 0 }]
|
||
number := rand.intn(100) or { 0 } // 0–99
|
||
|
||
return '${adj}_${noun}_${number}'
|
||
}
|