Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/config/store.go
3447 views
1
package config
2
3
import (
4
"fmt"
5
"github.com/kardolus/chatgpt-cli/internal"
6
"gopkg.in/yaml.v3"
7
"os"
8
"path/filepath"
9
"reflect"
10
"runtime"
11
"strconv"
12
"strings"
13
)
14
15
const (
16
openAIName = "openai"
17
openAIModel = "gpt-4o"
18
openAIMaxTokens = 4096
19
openAIContextWindow = 8192
20
openAIURL = "https://api.openai.com"
21
openAICompletionsPath = "/v1/chat/completions"
22
openAIResponsesPath = "/v1/responses"
23
openAITranscriptionsPath = "/v1/audio/transcriptions"
24
openAISpeechPath = "/v1/audio/speech"
25
openAIImageGenerationsPath = "/v1/images/generations"
26
openAIImageEditsPath = "/v1/images/edits"
27
openAIModelsPath = "/v1/models"
28
openAIAuthHeader = "Authorization"
29
openAIAuthTokenPrefix = "Bearer "
30
openAIRole = "You are a helpful assistant."
31
openAIThread = "default"
32
openAITemperature = 1.0
33
openAITopP = 1.0
34
openAIFrequencyPenalty = 0.0
35
openAIPresencePenalty = 0.0
36
openAICommandPrompt = "[%datetime] [Q%counter]"
37
openAIEffort = "low"
38
openAIVoice = "voice"
39
)
40
41
type Store interface {
42
Delete(string) error
43
List() ([]string, error)
44
Read() (Config, error)
45
ReadDefaults() Config
46
Write(Config) error
47
}
48
49
// FileNotFoundError is a custom error type for non-existent files
50
type FileNotFoundError struct {
51
Path string
52
}
53
54
func (e *FileNotFoundError) Error() string {
55
return fmt.Sprintf("no threads matched the pattern %s", e.Path)
56
}
57
58
// Ensure FileIO implements ConfigStore interface
59
var _ Store = &FileIO{}
60
61
type FileIO struct {
62
configFilePath string
63
historyFilePath string
64
}
65
66
func NewStore() *FileIO {
67
configPath, _ := getPath()
68
historyPath, _ := internal.GetDataHome()
69
70
return &FileIO{
71
configFilePath: configPath,
72
historyFilePath: historyPath,
73
}
74
}
75
76
func (f *FileIO) WithConfigPath(configFilePath string) *FileIO {
77
f.configFilePath = configFilePath
78
return f
79
}
80
81
func (f *FileIO) WithHistoryPath(historyPath string) *FileIO {
82
f.historyFilePath = historyPath
83
return f
84
}
85
86
func (f *FileIO) Delete(pattern string) error {
87
if !strings.HasSuffix(pattern, "*") && !strings.HasSuffix(pattern, ".json") {
88
pattern += ".json"
89
} else if strings.HasSuffix(pattern, "*") {
90
pattern += ".json"
91
}
92
93
fullPattern := filepath.Join(f.historyFilePath, pattern)
94
95
matches, err := filepath.Glob(fullPattern)
96
if err != nil {
97
return fmt.Errorf("failed to process pattern %s: %w", fullPattern, err)
98
}
99
100
if len(matches) == 0 {
101
return &FileNotFoundError{Path: fullPattern}
102
}
103
104
for _, path := range matches {
105
if err := os.Remove(path); err != nil {
106
return fmt.Errorf("failed to delete file %s: %w", path, err)
107
}
108
}
109
110
return nil
111
}
112
113
func (f *FileIO) List() ([]string, error) {
114
var result []string
115
116
files, err := os.ReadDir(f.historyFilePath)
117
if err != nil {
118
return nil, err
119
}
120
121
for _, file := range files {
122
result = append(result, file.Name())
123
}
124
125
return result, nil
126
}
127
128
func (f *FileIO) Read() (Config, error) {
129
result, err := parseFile(f.configFilePath)
130
if err != nil {
131
return Config{}, err
132
}
133
134
return migrate(result), nil
135
}
136
137
func (f *FileIO) ReadDefaults() Config {
138
return Config{
139
Name: openAIName,
140
Model: openAIModel,
141
Role: openAIRole,
142
MaxTokens: openAIMaxTokens,
143
ContextWindow: openAIContextWindow,
144
URL: openAIURL,
145
CompletionsPath: openAICompletionsPath,
146
ResponsesPath: openAIResponsesPath,
147
TranscriptionsPath: openAITranscriptionsPath,
148
SpeechPath: openAISpeechPath,
149
ImageGenerationsPath: openAIImageGenerationsPath,
150
ImageEditsPath: openAIImageEditsPath,
151
ModelsPath: openAIModelsPath,
152
AuthHeader: openAIAuthHeader,
153
AuthTokenPrefix: openAIAuthTokenPrefix,
154
Thread: openAIThread,
155
Temperature: openAITemperature,
156
TopP: openAITopP,
157
FrequencyPenalty: openAIFrequencyPenalty,
158
PresencePenalty: openAIPresencePenalty,
159
CommandPrompt: openAICommandPrompt,
160
Effort: openAIEffort,
161
Voice: openAIVoice,
162
}
163
}
164
165
func (f *FileIO) Write(config Config) error {
166
lock := newFileLock(f.configFilePath)
167
if err := lock.Lock(); err != nil {
168
return err
169
}
170
defer func() { _ = lock.Unlock() }()
171
172
rootNode, err := f.readNode()
173
174
// If readNode returns an error or there was a problem reading the rootNode, initialize a new rootNode.
175
if err != nil || rootNode.Kind == 0 {
176
rootNode = yaml.Node{Kind: yaml.DocumentNode}
177
rootNode.Content = append(rootNode.Content, &yaml.Node{Kind: yaml.MappingNode})
178
}
179
180
updateNodeFromConfig(&rootNode, config)
181
182
modifiedContent, err := yaml.Marshal(&rootNode)
183
if err != nil {
184
return err
185
}
186
187
perm := os.FileMode(0o600)
188
if st, err := os.Stat(f.configFilePath); err == nil {
189
perm = st.Mode() & 0o777
190
}
191
192
return atomicWriteFile(f.configFilePath, modifiedContent, perm)
193
}
194
195
func (f *FileIO) readNode() (yaml.Node, error) {
196
var rootNode yaml.Node
197
198
content, err := os.ReadFile(f.configFilePath)
199
if err != nil {
200
return rootNode, err
201
}
202
203
if err := yaml.Unmarshal(content, &rootNode); err != nil {
204
return rootNode, err
205
}
206
207
return rootNode, nil
208
}
209
210
func atomicWriteFile(filename string, data []byte, perm os.FileMode) error {
211
dir := filepath.Dir(filename)
212
213
tmp, err := os.CreateTemp(dir, "."+filepath.Base(filename)+".tmp-*")
214
if err != nil {
215
return fmt.Errorf("create temp: %w", err)
216
}
217
tmpName := tmp.Name()
218
219
defer func() {
220
_ = tmp.Close()
221
_ = os.Remove(tmpName)
222
}()
223
224
// Best-effort: make sure permissions are correct before rename.
225
if err := tmp.Chmod(perm); err != nil {
226
return fmt.Errorf("chmod temp: %w", err)
227
}
228
229
if _, err := tmp.Write(data); err != nil {
230
return fmt.Errorf("write temp: %w", err)
231
}
232
233
if err := tmp.Sync(); err != nil {
234
return fmt.Errorf("fsync temp: %w", err)
235
}
236
237
if err := tmp.Close(); err != nil {
238
return fmt.Errorf("close temp: %w", err)
239
}
240
241
if runtime.GOOS == "windows" {
242
_ = os.Remove(filename)
243
}
244
245
if err := os.Rename(tmpName, filename); err != nil {
246
return fmt.Errorf("rename temp -> target: %w", err)
247
}
248
249
if d, err := os.Open(dir); err == nil {
250
_ = d.Sync()
251
_ = d.Close()
252
}
253
254
return nil
255
}
256
257
func getPath() (string, error) {
258
homeDir, err := internal.GetConfigHome()
259
if err != nil {
260
return "", err
261
}
262
263
return filepath.Join(homeDir, "config.yaml"), nil
264
}
265
266
func migrate(config Config) Config {
267
// the "old" max_tokens became context_window
268
if config.ContextWindow == 0 && config.MaxTokens > 0 {
269
config.ContextWindow = config.MaxTokens
270
// set it to the default in case the value is small
271
if config.ContextWindow < openAIContextWindow {
272
config.ContextWindow = openAIContextWindow
273
}
274
config.MaxTokens = openAIMaxTokens
275
}
276
return config
277
}
278
279
func parseFile(fileName string) (Config, error) {
280
var result Config
281
282
buf, err := os.ReadFile(fileName)
283
if err != nil {
284
return Config{}, err
285
}
286
287
if err := yaml.Unmarshal(buf, &result); err != nil {
288
return Config{}, err
289
}
290
291
return result, nil
292
}
293
294
// updateNodeFromConfig updates the specified yaml.Node with values from the Config struct.
295
// It uses reflection to match struct fields with YAML tags, updating the node accordingly.
296
func updateNodeFromConfig(node *yaml.Node, config Config) {
297
t := reflect.TypeOf(config)
298
v := reflect.ValueOf(config)
299
300
for i := 0; i < t.NumField(); i++ {
301
field := t.Field(i)
302
value := v.Field(i)
303
yamlTag := field.Tag.Get("yaml")
304
305
if yamlTag == "" || yamlTag == "-" {
306
continue // Skip fields without yaml tag or marked to be ignored
307
}
308
309
// Convert value to string; adjust for different data types as needed
310
var strValue string
311
switch value.Kind() {
312
case reflect.String:
313
strValue = value.String()
314
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
315
strValue = strconv.FormatInt(value.Int(), 10)
316
case reflect.Float32, reflect.Float64:
317
strValue = strconv.FormatFloat(value.Float(), 'f', -1, 64)
318
case reflect.Bool:
319
strValue = strconv.FormatBool(value.Bool())
320
default:
321
continue // Skip unsupported types for simplicity
322
}
323
324
setField(node, yamlTag, strValue)
325
}
326
}
327
328
// setField either updates an existing field or adds a new field to the YAML mapping node.
329
// It assumes the root node is a DocumentNode containing a MappingNode.
330
func setField(root *yaml.Node, key string, newValue string) {
331
found := false
332
333
if root.Kind == yaml.DocumentNode {
334
root = root.Content[0] // Move from document node to the actual mapping node.
335
}
336
337
if root.Kind != yaml.MappingNode {
338
return // If the root is not a mapping node, we can't do anything.
339
}
340
341
for i := 0; i < len(root.Content); i += 2 {
342
keyNode := root.Content[i]
343
if keyNode.Value == key {
344
valueNode := root.Content[i+1]
345
valueNode.Value = newValue
346
found = true
347
break
348
}
349
}
350
351
if !found { // If the key wasn't found, add it.
352
root.Content = append(root.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, &yaml.Node{Kind: yaml.ScalarNode, Value: newValue})
353
}
354
}
355
356