slidict 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/changelog.yml +6 -23
- data/.github/workflows/gem-push.yml +38 -6
- data/AGENTS.md +2 -2
- data/README.md +53 -6
- data/lib/slidict/cli/app.rb +336 -0
- data/lib/slidict/cli/lint.rb +133 -0
- data/lib/slidict/cli/serve.rb +113 -0
- data/lib/slidict/cli/slides.rb +271 -0
- data/lib/slidict/external/slidict_io/auth.rb +80 -0
- data/lib/slidict/external/slidict_io/client.rb +126 -0
- data/lib/slidict/external/slidict_io/credentials.rb +43 -0
- data/lib/slidict/lint/finding.rb +10 -0
- data/lib/slidict/lint/linter.rb +22 -0
- data/lib/slidict/lint/renderer.rb +19 -0
- data/lib/slidict/lint/slide_parser.rb +68 -0
- data/lib/slidict/llm/client.rb +214 -0
- data/lib/slidict/output/format.rb +44 -0
- data/lib/slidict/output/renderer.rb +36 -0
- data/lib/slidict/version.rb +1 -1
- data/lib/slidict.rb +14 -7
- metadata +58 -9
- data/lib/slidict/auth_client.rb +0 -75
- data/lib/slidict/cli.rb +0 -266
- data/lib/slidict/credentials.rb +0 -39
- data/lib/slidict/llm_client.rb +0 -144
- data/lib/slidict/markdown_renderer.rb +0 -39
- data/lib/slidict/slides_client.rb +0 -120
- data/lib/slidict/slides_command.rb +0 -253
metadata
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: slidict
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yusuke Abe
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: puma
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '6.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '6.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rackup
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: sinatra
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '4.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '4.0'
|
|
12
54
|
description: Slidict is a Ruby CLI for turning rough ideas into presentation-ready
|
|
13
55
|
Markdown slides.
|
|
14
56
|
email:
|
|
@@ -31,15 +73,22 @@ files:
|
|
|
31
73
|
- README.md
|
|
32
74
|
- Rakefile
|
|
33
75
|
- lib/slidict.rb
|
|
34
|
-
- lib/slidict/
|
|
35
|
-
- lib/slidict/cli.rb
|
|
76
|
+
- lib/slidict/cli/app.rb
|
|
77
|
+
- lib/slidict/cli/lint.rb
|
|
78
|
+
- lib/slidict/cli/serve.rb
|
|
79
|
+
- lib/slidict/cli/slides.rb
|
|
36
80
|
- lib/slidict/config.rb
|
|
37
|
-
- lib/slidict/credentials.rb
|
|
38
81
|
- lib/slidict/deck.rb
|
|
39
|
-
- lib/slidict/
|
|
40
|
-
- lib/slidict/
|
|
41
|
-
- lib/slidict/
|
|
42
|
-
- lib/slidict/
|
|
82
|
+
- lib/slidict/external/slidict_io/auth.rb
|
|
83
|
+
- lib/slidict/external/slidict_io/client.rb
|
|
84
|
+
- lib/slidict/external/slidict_io/credentials.rb
|
|
85
|
+
- lib/slidict/lint/finding.rb
|
|
86
|
+
- lib/slidict/lint/linter.rb
|
|
87
|
+
- lib/slidict/lint/renderer.rb
|
|
88
|
+
- lib/slidict/lint/slide_parser.rb
|
|
89
|
+
- lib/slidict/llm/client.rb
|
|
90
|
+
- lib/slidict/output/format.rb
|
|
91
|
+
- lib/slidict/output/renderer.rb
|
|
43
92
|
- lib/slidict/version.rb
|
|
44
93
|
homepage: https://labs.slidict.io/slidict/
|
|
45
94
|
licenses:
|
data/lib/slidict/auth_client.rb
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
|
-
|
|
7
|
-
module Slidict
|
|
8
|
-
class AuthClient
|
|
9
|
-
Error = Class.new(StandardError)
|
|
10
|
-
Pending = Class.new(StandardError)
|
|
11
|
-
|
|
12
|
-
DEFAULT_BASE_URL = "https://slidict.io"
|
|
13
|
-
|
|
14
|
-
attr_reader :base_url
|
|
15
|
-
|
|
16
|
-
def initialize(base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
|
|
17
|
-
@base_url = base_url
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def request_device_code
|
|
21
|
-
response = post_json("/api/cli/device/code", { provider: "github" })
|
|
22
|
-
{
|
|
23
|
-
device_code: fetch!(response, "device_code"),
|
|
24
|
-
user_code: fetch!(response, "user_code"),
|
|
25
|
-
verification_uri: response["verification_uri"] || "#{base_url}/cli/activate",
|
|
26
|
-
interval: response.fetch("interval", 5).to_i,
|
|
27
|
-
expires_in: response.fetch("expires_in", 600).to_i
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def poll_token(device_code:)
|
|
32
|
-
response = post_json(
|
|
33
|
-
"/api/cli/device/token",
|
|
34
|
-
{ device_code: device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" },
|
|
35
|
-
raise_on_http_error: false
|
|
36
|
-
)
|
|
37
|
-
return response if response["access_token"]
|
|
38
|
-
|
|
39
|
-
error = response["error"].to_s
|
|
40
|
-
raise Pending if %w[authorization_pending slow_down].include?(error)
|
|
41
|
-
|
|
42
|
-
raise Error, response["error_description"] || error unless error.empty?
|
|
43
|
-
|
|
44
|
-
raise Error, "token response did not include access_token"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def post_json(path, payload, raise_on_http_error: true)
|
|
50
|
-
uri = URI.join(base_url, path)
|
|
51
|
-
request = Net::HTTP::Post.new(uri)
|
|
52
|
-
request["Accept"] = "application/json"
|
|
53
|
-
request["Content-Type"] = "application/json"
|
|
54
|
-
request.body = JSON.generate(payload)
|
|
55
|
-
|
|
56
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
57
|
-
http.request(request)
|
|
58
|
-
end
|
|
59
|
-
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
60
|
-
return body if response.is_a?(Net::HTTPSuccess) || !raise_on_http_error
|
|
61
|
-
|
|
62
|
-
raise Error, body["error_description"] || body["error"] || "HTTP #{response.code}"
|
|
63
|
-
rescue JSON::ParserError => e
|
|
64
|
-
raise Error, "invalid JSON response: #{e.message}"
|
|
65
|
-
rescue SystemCallError, Timeout::Error => e
|
|
66
|
-
raise Error, e.message
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def fetch!(hash, key)
|
|
70
|
-
hash.fetch(key)
|
|
71
|
-
rescue KeyError
|
|
72
|
-
raise Error, "device code response did not include #{key}"
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
data/lib/slidict/cli.rb
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Slidict
|
|
4
|
-
class CLI
|
|
5
|
-
DEFAULT_OUTPUT = "slides.md"
|
|
6
|
-
DEFAULT_OUTPUT_BY_FRAMEWORK = {
|
|
7
|
-
"slidev" => "slides.md",
|
|
8
|
-
"marp" => "slides.md",
|
|
9
|
-
"asciidoctor-revealjs" => "slides.adoc"
|
|
10
|
-
}.freeze
|
|
11
|
-
|
|
12
|
-
def initialize(input: $stdin, output: $stdout, renderer: MarkdownRenderer.new, auth_client: nil,
|
|
13
|
-
credentials: nil, sleeper: Kernel, slides_command: nil)
|
|
14
|
-
@input = input
|
|
15
|
-
@output = output
|
|
16
|
-
@renderer = renderer
|
|
17
|
-
@auth_client = auth_client
|
|
18
|
-
@credentials = credentials
|
|
19
|
-
@sleeper = sleeper
|
|
20
|
-
@slides_command = slides_command
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def run(argv = [])
|
|
24
|
-
options = parse(argv)
|
|
25
|
-
return print_help if options[:help]
|
|
26
|
-
return auth if options[:command] == "auth"
|
|
27
|
-
return slides(options[:args]) if options[:command] == "slides"
|
|
28
|
-
|
|
29
|
-
config = build_config(options)
|
|
30
|
-
client = llm_client_for(config)
|
|
31
|
-
return 1 if client && !verify_connection(client)
|
|
32
|
-
|
|
33
|
-
deck = Deck.new(
|
|
34
|
-
topic: ask("What would you like to talk about?", options[:topic]),
|
|
35
|
-
duration: ask("How long is the presentation?", options[:duration]),
|
|
36
|
-
audience: ask("Who is the audience?", options[:audience]),
|
|
37
|
-
goal: ask("What should the audience remember or do?", options[:goal]),
|
|
38
|
-
framework: options[:framework]
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
if client
|
|
42
|
-
begin
|
|
43
|
-
slides = client.generate_slides(deck)
|
|
44
|
-
rescue LLMClient::Error => e
|
|
45
|
-
@output.puts "Error: LLM request failed (#{e.message})"
|
|
46
|
-
return 1
|
|
47
|
-
end
|
|
48
|
-
deck = Deck.new(
|
|
49
|
-
topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
|
|
50
|
-
framework: deck.framework, slides: slides
|
|
51
|
-
)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
path = options[:output]
|
|
55
|
-
content = @renderer.render(deck)
|
|
56
|
-
File.write(path, content)
|
|
57
|
-
@output.puts "Created #{path}"
|
|
58
|
-
|
|
59
|
-
return publish_to_slidict(deck, content, options) if options[:publish] || options[:slide_id]
|
|
60
|
-
|
|
61
|
-
0
|
|
62
|
-
rescue ArgumentError => e
|
|
63
|
-
@output.puts "Error: #{e.message}"
|
|
64
|
-
@output.puts
|
|
65
|
-
print_help
|
|
66
|
-
1
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
private
|
|
70
|
-
|
|
71
|
-
def parse(argv)
|
|
72
|
-
options = { framework: "slidev" }
|
|
73
|
-
args = argv.dup
|
|
74
|
-
|
|
75
|
-
if args.first == "auth"
|
|
76
|
-
args.shift
|
|
77
|
-
raise ArgumentError, "auth does not accept options" unless args.empty?
|
|
78
|
-
|
|
79
|
-
options[:command] = "auth"
|
|
80
|
-
return options
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
if args.first == "slides"
|
|
84
|
-
args.shift
|
|
85
|
-
options[:command] = "slides"
|
|
86
|
-
options[:args] = args
|
|
87
|
-
return options
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
until args.empty?
|
|
91
|
-
case (arg = args.shift)
|
|
92
|
-
when "-h", "--help"
|
|
93
|
-
options[:help] = true
|
|
94
|
-
when "-o", "--output"
|
|
95
|
-
options[:output] = fetch_value!(args, arg)
|
|
96
|
-
when "--topic"
|
|
97
|
-
options[:topic] = fetch_value!(args, arg)
|
|
98
|
-
when "--duration"
|
|
99
|
-
options[:duration] = fetch_value!(args, arg)
|
|
100
|
-
when "--audience"
|
|
101
|
-
options[:audience] = fetch_value!(args, arg)
|
|
102
|
-
when "--goal"
|
|
103
|
-
options[:goal] = fetch_value!(args, arg)
|
|
104
|
-
when "--framework"
|
|
105
|
-
options[:framework] = fetch_value!(args, arg)
|
|
106
|
-
when "--llm-base-url"
|
|
107
|
-
options[:llm_base_url] = fetch_value!(args, arg)
|
|
108
|
-
when "--llm-api-key"
|
|
109
|
-
options[:llm_api_key] = fetch_value!(args, arg)
|
|
110
|
-
when "--llm-model"
|
|
111
|
-
options[:llm_model] = fetch_value!(args, arg)
|
|
112
|
-
when "--no-llm"
|
|
113
|
-
options[:no_llm] = true
|
|
114
|
-
when "--publish"
|
|
115
|
-
options[:publish] = true
|
|
116
|
-
when "--slide-id"
|
|
117
|
-
options[:slide_id] = fetch_value!(args, arg)
|
|
118
|
-
when "--slide-title"
|
|
119
|
-
options[:slide_title] = fetch_value!(args, arg)
|
|
120
|
-
when "--visibility"
|
|
121
|
-
options[:visibility] = fetch_value!(args, arg)
|
|
122
|
-
else
|
|
123
|
-
raise ArgumentError, "unknown option #{arg}"
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
options[:output] ||= default_output_for(options[:framework])
|
|
128
|
-
options
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def build_config(options)
|
|
132
|
-
Config.from_env.merge(
|
|
133
|
-
base_url: options[:llm_base_url],
|
|
134
|
-
api_key: options[:llm_api_key],
|
|
135
|
-
model: options[:llm_model],
|
|
136
|
-
enabled: options[:no_llm] ? false : nil
|
|
137
|
-
)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def llm_client_for(config)
|
|
141
|
-
return nil unless config.llm_enabled?
|
|
142
|
-
|
|
143
|
-
LLMClient.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def verify_connection(client)
|
|
147
|
-
client.verify_connection!
|
|
148
|
-
true
|
|
149
|
-
rescue LLMClient::Error => e
|
|
150
|
-
@output.puts "Error: LLM request failed (#{e.message})"
|
|
151
|
-
false
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def auth
|
|
155
|
-
client = @auth_client || AuthClient.new
|
|
156
|
-
credentials = @credentials || Credentials.new
|
|
157
|
-
|
|
158
|
-
device = client.request_device_code
|
|
159
|
-
@output.puts "1. Open #{device[:verification_uri]} in your browser"
|
|
160
|
-
@output.puts "2. Enter code: #{device[:user_code]}"
|
|
161
|
-
@output.puts "3. Log in with GitHub"
|
|
162
|
-
@output.puts "Waiting for GitHub authentication..."
|
|
163
|
-
|
|
164
|
-
deadline = Time.now + device[:expires_in]
|
|
165
|
-
loop do
|
|
166
|
-
token = client.poll_token(device_code: device[:device_code])
|
|
167
|
-
path = credentials.write_cli_token!(
|
|
168
|
-
access_token: token.fetch("access_token"),
|
|
169
|
-
token_type: token.fetch("token_type", "Bearer"),
|
|
170
|
-
provider: token.fetch("provider", "github")
|
|
171
|
-
)
|
|
172
|
-
@output.puts "4. Saved CLI access token to #{path}"
|
|
173
|
-
return 0
|
|
174
|
-
rescue AuthClient::Pending
|
|
175
|
-
return login_expired if Time.now >= deadline
|
|
176
|
-
|
|
177
|
-
@sleeper.sleep(device[:interval])
|
|
178
|
-
end
|
|
179
|
-
rescue AuthClient::Error, KeyError => e
|
|
180
|
-
@output.puts "Error: GitHub auth failed (#{e.message})"
|
|
181
|
-
1
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def slides(args)
|
|
185
|
-
slides_command.run(args)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def publish_to_slidict(deck, content, options)
|
|
189
|
-
slides_command.publish(
|
|
190
|
-
id: options[:slide_id],
|
|
191
|
-
title: options[:slide_title] || deck.topic,
|
|
192
|
-
body: content,
|
|
193
|
-
body_format: body_format_for(deck.framework),
|
|
194
|
-
visibility: options[:visibility]
|
|
195
|
-
)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def slides_command
|
|
199
|
-
@slides_command ||= SlidesCommand.new(output: @output, credentials: @credentials, reauthenticate: method(:auth))
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def body_format_for(framework)
|
|
203
|
-
framework.to_s.downcase == "asciidoctor-revealjs" ? "asciidoc" : "markdown"
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def login_expired
|
|
207
|
-
@output.puts "Error: GitHub auth timed out. Run `slidict auth` and try again."
|
|
208
|
-
1
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def fetch_value!(args, option)
|
|
212
|
-
value = args.shift
|
|
213
|
-
raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
|
|
214
|
-
|
|
215
|
-
value
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def ask(question, provided)
|
|
219
|
-
return provided unless provided.nil? || provided.strip.empty?
|
|
220
|
-
|
|
221
|
-
@output.puts question
|
|
222
|
-
@output.print "> "
|
|
223
|
-
@input.gets&.chomp.to_s
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def print_help
|
|
227
|
-
@output.puts <<~HELP
|
|
228
|
-
Usage: slidict [options]
|
|
229
|
-
Usage: slidict auth
|
|
230
|
-
Usage: slidict slides <list|show|create|edit> [options]
|
|
231
|
-
|
|
232
|
-
Generate presentation source files from a short conversation.
|
|
233
|
-
|
|
234
|
-
Commands:
|
|
235
|
-
auth Authenticate the CLI with GitHub and save a CLI access token
|
|
236
|
-
slides Manage your slides on slidict.io (run `slidict slides -h` for details)
|
|
237
|
-
|
|
238
|
-
Options:
|
|
239
|
-
--topic TEXT Presentation topic
|
|
240
|
-
--duration TEXT Presentation length, for example "5 minutes"
|
|
241
|
-
--audience TEXT Target audience
|
|
242
|
-
--goal TEXT Desired audience takeaway or action
|
|
243
|
-
--framework NAME slidev, marp, or asciidoctor-revealjs (default: slidev)
|
|
244
|
-
--llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
|
|
245
|
-
When omitted, the built-in slide template is used instead.
|
|
246
|
-
--llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
|
|
247
|
-
--llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
|
|
248
|
-
--no-llm Skip the LLM call and use the built-in slide template
|
|
249
|
-
--publish Publish the generated slides to slidict.io as a draft
|
|
250
|
-
(requires `slidict auth`; creates a new slide, or edits
|
|
251
|
-
an existing one when --slide-id is given)
|
|
252
|
-
--slide-id ID Edit this existing draft instead of creating a new one
|
|
253
|
-
(implies --publish)
|
|
254
|
-
--slide-title TEXT Title for the published slide (default: --topic)
|
|
255
|
-
--visibility VIS public, unlisted, or group_only (default: public)
|
|
256
|
-
-o, --output PATH Output file (default depends on --framework)
|
|
257
|
-
-h, --help Show this help
|
|
258
|
-
HELP
|
|
259
|
-
0
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def default_output_for(framework)
|
|
263
|
-
DEFAULT_OUTPUT_BY_FRAMEWORK.fetch(framework.to_s.downcase, DEFAULT_OUTPUT)
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
end
|
data/lib/slidict/credentials.rb
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module Slidict
|
|
7
|
-
class Credentials
|
|
8
|
-
DEFAULT_PATH = File.expand_path("~/.config/slidict/credentials.json")
|
|
9
|
-
|
|
10
|
-
def initialize(path: DEFAULT_PATH)
|
|
11
|
-
@path = path
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def write_cli_token!(access_token:, token_type: "Bearer", provider: "github")
|
|
15
|
-
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
16
|
-
json = JSON.pretty_generate(
|
|
17
|
-
"access_token" => access_token,
|
|
18
|
-
"token_type" => token_type,
|
|
19
|
-
"provider" => provider,
|
|
20
|
-
"kind" => "cli_access_token"
|
|
21
|
-
)
|
|
22
|
-
File.write(@path, "#{json}\n", mode: "w", perm: 0o600)
|
|
23
|
-
File.chmod(0o600, @path)
|
|
24
|
-
@path
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Returns { access_token:, token_type: } or nil if no CLI access token is saved.
|
|
28
|
-
def read_cli_token
|
|
29
|
-
return nil unless File.exist?(@path)
|
|
30
|
-
|
|
31
|
-
data = JSON.parse(File.read(@path))
|
|
32
|
-
return nil unless data["kind"] == "cli_access_token" && data["access_token"]
|
|
33
|
-
|
|
34
|
-
{ access_token: data["access_token"], token_type: data.fetch("token_type", "Bearer") }
|
|
35
|
-
rescue JSON::ParserError
|
|
36
|
-
nil
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
data/lib/slidict/llm_client.rb
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "net/http"
|
|
4
|
-
require "uri"
|
|
5
|
-
require "json"
|
|
6
|
-
|
|
7
|
-
module Slidict
|
|
8
|
-
# Talks to any OpenAI Compatible API (OpenAI, Ollama, LM Studio, vLLM, etc.)
|
|
9
|
-
# via the standard /chat/completions endpoint. Configure the target with
|
|
10
|
-
# Slidict::Config (base_url, api_key, model).
|
|
11
|
-
class LLMClient
|
|
12
|
-
class Error < StandardError; end
|
|
13
|
-
|
|
14
|
-
def initialize(base_url:, api_key:, model:)
|
|
15
|
-
@base_url = base_url
|
|
16
|
-
@api_key = api_key
|
|
17
|
-
@model = model
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Checks that the endpoint is reachable before the (slower, more
|
|
21
|
-
# expensive) chat completion request is made. Raises Error on failure.
|
|
22
|
-
def verify_connection!
|
|
23
|
-
uri = endpoint_uri("models")
|
|
24
|
-
request = Net::HTTP::Get.new(uri)
|
|
25
|
-
request["Authorization"] = "Bearer #{@api_key}"
|
|
26
|
-
response = perform_request(uri, request)
|
|
27
|
-
|
|
28
|
-
raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def generate_slides(deck)
|
|
32
|
-
content = chat_completion(prompt_for(deck))
|
|
33
|
-
slides_from(content)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def prompt_for(deck)
|
|
39
|
-
<<~PROMPT
|
|
40
|
-
You are an assistant that designs presentation slide outlines.
|
|
41
|
-
Topic: #{deck.topic}
|
|
42
|
-
Duration: #{deck.duration}
|
|
43
|
-
Audience: #{deck.audience}
|
|
44
|
-
Goal: #{deck.goal}
|
|
45
|
-
|
|
46
|
-
Return exactly 5 slides as a JSON array. Each item must be an object with
|
|
47
|
-
a "title" string and a "bullets" array of 2-4 short strings.
|
|
48
|
-
Respond with the JSON array only: no commentary, no markdown code fences,
|
|
49
|
-
and no reasoning or thinking content before or after it.
|
|
50
|
-
PROMPT
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def chat_completion(prompt)
|
|
54
|
-
response = JSON.parse(post_chat_completion(prompt))
|
|
55
|
-
content = response.dig("choices", 0, "message", "content")
|
|
56
|
-
raise Error, "empty response from model" if content.to_s.strip.empty?
|
|
57
|
-
|
|
58
|
-
content
|
|
59
|
-
rescue JSON::ParserError => e
|
|
60
|
-
raise Error, "could not parse model response: #{e.message}"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def post_chat_completion(prompt)
|
|
64
|
-
uri = endpoint_uri("chat/completions")
|
|
65
|
-
request = build_request(uri, prompt)
|
|
66
|
-
response = perform_request(uri, request)
|
|
67
|
-
|
|
68
|
-
raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
69
|
-
|
|
70
|
-
response.body
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def perform_request(uri, request)
|
|
74
|
-
Net::HTTP.start(uri.host, uri.port,
|
|
75
|
-
use_ssl: uri.scheme == "https",
|
|
76
|
-
open_timeout: 5,
|
|
77
|
-
read_timeout: 30,
|
|
78
|
-
write_timeout: 30) do |http|
|
|
79
|
-
http.request(request)
|
|
80
|
-
end
|
|
81
|
-
rescue StandardError => e
|
|
82
|
-
raise Error, e.message
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def endpoint_uri(path)
|
|
86
|
-
base = @base_url.to_s.sub(%r{/+\z}, "")
|
|
87
|
-
URI.join("#{base}/", path)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def build_request(uri, prompt)
|
|
91
|
-
request = Net::HTTP::Post.new(uri)
|
|
92
|
-
request["Content-Type"] = "application/json"
|
|
93
|
-
request["Authorization"] = "Bearer #{@api_key}"
|
|
94
|
-
request.body = JSON.generate(
|
|
95
|
-
model: @model,
|
|
96
|
-
messages: [{ role: "user", content: prompt }],
|
|
97
|
-
temperature: 0.7
|
|
98
|
-
)
|
|
99
|
-
request
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def slides_from(content)
|
|
103
|
-
parsed = JSON.parse(extract_json_array(content))
|
|
104
|
-
raise Error, "expected a JSON array of slides" unless parsed.is_a?(Array)
|
|
105
|
-
|
|
106
|
-
parsed.map do |item|
|
|
107
|
-
Slide.new(title: item.fetch("title"), bullets: Array(item.fetch("bullets")))
|
|
108
|
-
end
|
|
109
|
-
rescue JSON::ParserError, KeyError => e
|
|
110
|
-
raise Error, "could not parse model response: #{e.message}"
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# Some models (especially reasoning models served through LM Studio or
|
|
114
|
-
# Ollama) prepend or append thinking/reasoning text around the JSON
|
|
115
|
-
# answer instead of returning it verbatim, so the array is extracted from
|
|
116
|
-
# within the raw content rather than parsed as-is.
|
|
117
|
-
def extract_json_array(content)
|
|
118
|
-
content.enum_for(:scan, /\[/).each do
|
|
119
|
-
start = Regexp.last_match.begin(0)
|
|
120
|
-
candidate = json_array_from(content, start)
|
|
121
|
-
return candidate if candidate
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
raise Error, "no JSON array found in model response"
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def json_array_from(content, start)
|
|
128
|
-
finish = start
|
|
129
|
-
while (finish = content.index("]", finish))
|
|
130
|
-
candidate = content[start..finish]
|
|
131
|
-
return candidate if parses_to_array?(candidate)
|
|
132
|
-
|
|
133
|
-
finish += 1
|
|
134
|
-
end
|
|
135
|
-
nil
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def parses_to_array?(candidate)
|
|
139
|
-
JSON.parse(candidate).is_a?(Array)
|
|
140
|
-
rescue JSON::ParserError
|
|
141
|
-
false
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
end
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Slidict
|
|
4
|
-
class MarkdownRenderer
|
|
5
|
-
FRONTMATTER_BY_FRAMEWORK = {
|
|
6
|
-
"slidev" => "theme: default\nclass: text-center",
|
|
7
|
-
"marp" => "marp: true\ntheme: default"
|
|
8
|
-
}.freeze
|
|
9
|
-
|
|
10
|
-
def render(deck)
|
|
11
|
-
return render_asciidoctor_revealjs(deck) if deck.framework == "asciidoctor-revealjs"
|
|
12
|
-
|
|
13
|
-
[frontmatter(deck.framework), deck.slides.map { |slide| render_slide(slide) }.join("\n---\n\n")].join("\n")
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
def frontmatter(framework)
|
|
19
|
-
body = FRONTMATTER_BY_FRAMEWORK.fetch(framework, FRONTMATTER_BY_FRAMEWORK["slidev"])
|
|
20
|
-
"---\n#{body}\ngenerated: #{Time.now.utc.iso8601}\n---\n"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def render_slide(slide)
|
|
24
|
-
lines = ["# #{slide.title}", ""]
|
|
25
|
-
lines.concat(slide.bullets.map { |bullet| "- #{bullet}" })
|
|
26
|
-
lines.join("\n")
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def render_asciidoctor_revealjs(deck)
|
|
30
|
-
lines = ["= #{deck.topic}", ":revealjs_theme: white", ":slidict_generated: #{Time.now.utc.iso8601}", ""]
|
|
31
|
-
lines.concat(deck.slides.flat_map { |slide| render_asciidoctor_slide(slide) })
|
|
32
|
-
lines.join("\n")
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def render_asciidoctor_slide(slide)
|
|
36
|
-
["== #{slide.title}", "", *slide.bullets.map { |bullet| "* #{bullet}" }, ""]
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|