feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
243
backend/internal/admin/config.go
Normal file
243
backend/internal/admin/config.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultAdminAddr = ":8080"
|
||||
const defaultMigrationsDir = "migrations"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DatabaseURL string
|
||||
MigrationsDir string
|
||||
RepoRoot string
|
||||
PostsDir string
|
||||
SiteDir string
|
||||
AssetsDir string
|
||||
AdminDir string
|
||||
DeepSeek DeepSeekConfig
|
||||
LocalLLM LocalLLMConfig
|
||||
SlugProvider string
|
||||
}
|
||||
|
||||
type DeepSeekConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
}
|
||||
|
||||
type LocalLLMConfig struct {
|
||||
URL string
|
||||
Model string
|
||||
Temperature float64
|
||||
TopP float64
|
||||
NumPredict int
|
||||
}
|
||||
|
||||
type localConfig struct {
|
||||
Database struct {
|
||||
PostgresDSN string `yaml:"postgres_dsn"`
|
||||
} `yaml:"database"`
|
||||
Slug struct {
|
||||
Provider string `yaml:"provider"`
|
||||
} `yaml:"slug"`
|
||||
DeepSeek struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
APIKeyEnv string `yaml:"api_key_env"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Model string `yaml:"model"`
|
||||
} `yaml:"deepseek"`
|
||||
LocalLLM struct {
|
||||
URL string `yaml:"url"`
|
||||
Model string `yaml:"model"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
TopP float64 `yaml:"top_p"`
|
||||
NumPredict int `yaml:"num_predict"`
|
||||
} `yaml:"local_llm"`
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
addr := os.Getenv("OSAET_ADMIN_ADDR")
|
||||
if addr == "" {
|
||||
addr = defaultAdminAddr
|
||||
}
|
||||
|
||||
migrationsDir := os.Getenv("OSAET_MIGRATIONS_DIR")
|
||||
if migrationsDir == "" {
|
||||
migrationsDir = defaultMigrationsDir
|
||||
}
|
||||
|
||||
repoRoot := os.Getenv("OSAET_REPO_ROOT")
|
||||
if repoRoot == "" {
|
||||
repoRoot = ".."
|
||||
}
|
||||
|
||||
postsDir := os.Getenv("OSAET_POSTS_DIR")
|
||||
if postsDir == "" {
|
||||
postsDir = filepath.Join(repoRoot, "content", "posts")
|
||||
}
|
||||
|
||||
siteDir := os.Getenv("OSAET_SITE_DIR")
|
||||
if siteDir == "" {
|
||||
siteDir = filepath.Join(repoRoot, "frontend", "site")
|
||||
}
|
||||
|
||||
assetsDir := os.Getenv("OSAET_ASSETS_DIR")
|
||||
if assetsDir == "" {
|
||||
assetsDir = filepath.Join(siteDir, "public", "assets")
|
||||
}
|
||||
|
||||
adminDir := os.Getenv("OSAET_ADMIN_DIR")
|
||||
if adminDir == "" {
|
||||
adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser")
|
||||
}
|
||||
|
||||
local := loadLocalConfig(repoRoot)
|
||||
databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN)
|
||||
|
||||
deepSeekAPIKeyEnv := firstNonEmpty(local.DeepSeek.APIKeyEnv, "DEEPSEEK_API_KEY")
|
||||
deepSeekAPIKey := strings.TrimSpace(os.Getenv(deepSeekAPIKeyEnv))
|
||||
if deepSeekAPIKey == "" && deepSeekAPIKeyEnv != "DEEPSEEK_API_KEY" {
|
||||
deepSeekAPIKey = strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))
|
||||
}
|
||||
if deepSeekAPIKey == "" {
|
||||
deepSeekAPIKey = strings.TrimSpace(local.DeepSeek.APIKey)
|
||||
}
|
||||
|
||||
return Config{
|
||||
Addr: addr,
|
||||
DatabaseURL: databaseURL,
|
||||
MigrationsDir: migrationsDir,
|
||||
RepoRoot: repoRoot,
|
||||
PostsDir: postsDir,
|
||||
SiteDir: siteDir,
|
||||
AssetsDir: assetsDir,
|
||||
AdminDir: adminDir,
|
||||
SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"),
|
||||
DeepSeek: DeepSeekConfig{
|
||||
APIKey: deepSeekAPIKey,
|
||||
BaseURL: firstNonEmpty(os.Getenv("DEEPSEEK_BASE_URL"), local.DeepSeek.BaseURL),
|
||||
Model: firstNonEmpty(os.Getenv("DEEPSEEK_MODEL"), local.DeepSeek.Model),
|
||||
},
|
||||
LocalLLM: LocalLLMConfig{
|
||||
URL: firstNonEmpty(os.Getenv("LOCAL_LLM_URL"), local.LocalLLM.URL, "http://127.0.0.1:11434/api/generate"),
|
||||
Model: firstNonEmpty(os.Getenv("LOCAL_LLM_MODEL"), local.LocalLLM.Model),
|
||||
Temperature: firstNonZeroFloat(envFloat("LOCAL_LLM_TEMPERATURE"), local.LocalLLM.Temperature, 0.1),
|
||||
TopP: firstNonZeroFloat(envFloat("LOCAL_LLM_TOP_P"), local.LocalLLM.TopP, 0.8),
|
||||
NumPredict: firstNonZeroInt(envInt("LOCAL_LLM_NUM_PREDICT"), local.LocalLLM.NumPredict, 32),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadLocalConfig(repoRoot string) localConfig {
|
||||
var config localConfig
|
||||
paths := []string{}
|
||||
if path := strings.TrimSpace(os.Getenv("OSAET_LOCAL_CONFIG")); path != "" {
|
||||
paths = append(paths, path)
|
||||
} else {
|
||||
paths = append(paths,
|
||||
filepath.Join(repoRoot, "config", "local.yaml"),
|
||||
filepath.Join("config", "local.yaml"),
|
||||
filepath.Join("..", "config", "local.yaml"),
|
||||
)
|
||||
}
|
||||
|
||||
path, data, ok := readFirstExistingFile(paths)
|
||||
if !ok {
|
||||
return config
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to read %s: %v\n", path, err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func readFirstExistingFile(paths []string) (string, []byte, bool) {
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return path, data, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonZeroFloat(values ...float64) float64 {
|
||||
for _, value := range values {
|
||||
if value != 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonZeroInt(values ...int) int {
|
||||
for _, value := range values {
|
||||
if value != 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func envFloat(key string) float64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
var parsed float64
|
||||
if _, err := fmt.Sscanf(value, "%f", &parsed); err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envInt(key string) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Addr == "" {
|
||||
return errors.New("admin addr is required")
|
||||
}
|
||||
if c.DatabaseURL == "" {
|
||||
return errors.New("DATABASE_URL or config/local.yaml database.postgres_dsn is required")
|
||||
}
|
||||
if c.MigrationsDir == "" {
|
||||
return errors.New("migrations dir is required")
|
||||
}
|
||||
if c.PostsDir == "" {
|
||||
return errors.New("posts dir is required")
|
||||
}
|
||||
if c.SiteDir == "" {
|
||||
return errors.New("site dir is required")
|
||||
}
|
||||
if c.AssetsDir == "" {
|
||||
return errors.New("assets dir is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue