codeblog-app 1.5.2 → 1.6.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.
@@ -0,0 +1,209 @@
1
+ import { createSignal, createMemo, onMount, Show, For } from "solid-js"
2
+ import { useKeyboard, usePaste } from "@opentui/solid"
3
+ import { useTheme } from "../context/theme"
4
+
5
+ interface ModelItem {
6
+ id: string
7
+ name: string
8
+ provider: string
9
+ }
10
+
11
+ export function ModelPicker(props: { onDone: (model?: string) => void }) {
12
+ const theme = useTheme()
13
+ const [models, setModels] = createSignal<ModelItem[]>([])
14
+ const [idx, setIdx] = createSignal(0)
15
+ const [current, setCurrent] = createSignal("")
16
+ const [loading, setLoading] = createSignal(true)
17
+ const [filter, setFilter] = createSignal("")
18
+ const [status, setStatus] = createSignal("")
19
+
20
+ // Visible height for scrolling
21
+ const maxVisible = 15
22
+
23
+ const filtered = createMemo(() => {
24
+ const q = filter().toLowerCase()
25
+ if (!q) return models()
26
+ return models().filter((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
27
+ })
28
+
29
+ // Scroll offset
30
+ const scrollTop = createMemo(() => {
31
+ const i = idx()
32
+ const len = filtered().length
33
+ if (len <= maxVisible) return 0
34
+ if (i < maxVisible - 2) return 0
35
+ if (i > len - 3) return Math.max(0, len - maxVisible)
36
+ return i - maxVisible + 3
37
+ })
38
+
39
+ const visible = createMemo(() => filtered().slice(scrollTop(), scrollTop() + maxVisible))
40
+
41
+ onMount(async () => {
42
+ try {
43
+ const { AIProvider } = await import("../../ai/provider")
44
+ const { Config } = await import("../../config")
45
+ const cfg = await Config.load()
46
+ setCurrent(cfg.model || AIProvider.DEFAULT_MODEL)
47
+
48
+ setStatus("Fetching models from API...")
49
+ const dynamic = await AIProvider.fetchAllModels()
50
+ if (dynamic.length > 0) {
51
+ setModels(dynamic.map((m) => ({ id: m.id, name: m.name, provider: m.providerID })))
52
+ const curIdx = dynamic.findIndex((m) => m.id === (cfg.model || AIProvider.DEFAULT_MODEL))
53
+ if (curIdx >= 0) setIdx(curIdx)
54
+ setStatus(`${dynamic.length} models loaded`)
55
+ } else {
56
+ // Fallback to builtin
57
+ const all = await AIProvider.available()
58
+ const items = all.filter((m) => m.hasKey).map((m) => ({
59
+ id: m.model.id,
60
+ name: m.model.name,
61
+ provider: m.model.providerID,
62
+ }))
63
+ setModels(items)
64
+ setStatus(`${items.length} models (builtin)`)
65
+ }
66
+ } catch (err) {
67
+ setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`)
68
+ }
69
+ setLoading(false)
70
+ })
71
+
72
+ usePaste((evt) => {
73
+ const text = evt.text.replace(/[\n\r]/g, "").trim()
74
+ if (text) {
75
+ setFilter((s) => s + text)
76
+ setIdx(0)
77
+ evt.preventDefault()
78
+ }
79
+ })
80
+
81
+ useKeyboard((evt) => {
82
+ if (evt.name === "up") {
83
+ setIdx((i) => (i - 1 + filtered().length) % filtered().length)
84
+ evt.preventDefault()
85
+ return
86
+ }
87
+ if (evt.name === "down") {
88
+ setIdx((i) => (i + 1) % filtered().length)
89
+ evt.preventDefault()
90
+ return
91
+ }
92
+ if (evt.name === "return") {
93
+ const m = filtered()[idx()]
94
+ if (m) save(m.id)
95
+ evt.preventDefault()
96
+ return
97
+ }
98
+ if (evt.name === "escape") {
99
+ if (filter()) { setFilter(""); setIdx(0) }
100
+ else props.onDone()
101
+ evt.preventDefault()
102
+ return
103
+ }
104
+ if (evt.name === "backspace") {
105
+ setFilter((s) => s.slice(0, -1))
106
+ setIdx(0)
107
+ evt.preventDefault()
108
+ return
109
+ }
110
+ if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
111
+ const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
112
+ if (clean) {
113
+ setFilter((s) => s + clean)
114
+ setIdx(0)
115
+ evt.preventDefault()
116
+ return
117
+ }
118
+ }
119
+ if (evt.name === "space") {
120
+ setFilter((s) => s + " ")
121
+ evt.preventDefault()
122
+ return
123
+ }
124
+ })
125
+
126
+ async function save(id: string) {
127
+ try {
128
+ const { Config } = await import("../../config")
129
+ await Config.save({ model: id })
130
+ props.onDone(id)
131
+ } catch {
132
+ props.onDone()
133
+ }
134
+ }
135
+
136
+ return (
137
+ <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1}>
138
+ <box flexDirection="row" flexShrink={0}>
139
+ <text fg={theme.colors.primary}>
140
+ <span style={{ bold: true }}>Select Model</span>
141
+ </text>
142
+ <box flexGrow={1} />
143
+ <text fg={theme.colors.textMuted}>
144
+ ↑↓ select · Enter confirm · Type to filter · Esc back
145
+ </text>
146
+ </box>
147
+
148
+ <box flexShrink={0} flexDirection="row" paddingTop={1}>
149
+ <text fg={theme.colors.textMuted}>Current: </text>
150
+ <text fg={theme.colors.success}>{current()}</text>
151
+ <text fg={theme.colors.textMuted}>{" · " + status()}</text>
152
+ </box>
153
+
154
+ {/* Filter input */}
155
+ <box flexShrink={0} paddingTop={1} flexDirection="row">
156
+ <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
157
+ <text fg={theme.colors.input}>{filter()}</text>
158
+ <text fg={theme.colors.cursor}>{"█"}</text>
159
+ <Show when={!filter()}>
160
+ <text fg={theme.colors.textMuted}> type to filter...</text>
161
+ </Show>
162
+ </box>
163
+
164
+ <box height={1} />
165
+
166
+ <Show when={loading()}>
167
+ <text fg={theme.colors.textMuted}>Fetching models from API...</text>
168
+ </Show>
169
+
170
+ <Show when={!loading()}>
171
+ <Show when={filtered().length === 0}>
172
+ <text fg={theme.colors.warning}>No models match "{filter()}"</text>
173
+ </Show>
174
+ <Show when={filtered().length > 0}>
175
+ <box flexDirection="column" flexShrink={0}>
176
+ <For each={visible()}>
177
+ {(m, i) => {
178
+ const selected = () => scrollTop() + i() === idx()
179
+ return (
180
+ <box flexDirection="row" backgroundColor={selected() ? theme.colors.primary : undefined}>
181
+ <text fg={selected() ? "#ffffff" : theme.colors.textMuted}>
182
+ {selected() ? "❯ " : " "}
183
+ </text>
184
+ <text fg={selected() ? "#ffffff" : theme.colors.text}>
185
+ <span style={{ bold: selected() }}>
186
+ {m.id.length > 40 ? m.id.slice(0, 40) + "…" : m.id.padEnd(42)}
187
+ </span>
188
+ </text>
189
+ <text fg={selected() ? "#ffffff" : theme.colors.textMuted}>
190
+ {" " + m.provider}
191
+ </text>
192
+ {m.id === current() ? (
193
+ <text fg={selected() ? "#ffffff" : theme.colors.success}>{" ← current"}</text>
194
+ ) : null}
195
+ </box>
196
+ )
197
+ }}
198
+ </For>
199
+ </box>
200
+ <Show when={filtered().length > maxVisible}>
201
+ <text fg={theme.colors.textMuted}>
202
+ {" ↑↓ " + filtered().length + " models total · showing " + (scrollTop() + 1) + "-" + Math.min(scrollTop() + maxVisible, filtered().length)}
203
+ </text>
204
+ </Show>
205
+ </Show>
206
+ </Show>
207
+ </box>
208
+ )
209
+ }
@@ -1,210 +0,0 @@
1
- import { createSignal, For, Show, onMount } from "solid-js"
2
- import { useKeyboard } from "@opentui/solid"
3
- import { useRoute } from "../context/route"
4
- import { useTheme } from "../context/theme"
5
-
6
- interface Message {
7
- role: "user" | "assistant"
8
- content: string
9
- }
10
-
11
- export function Chat() {
12
- const route = useRoute()
13
- const theme = useTheme()
14
- const [messages, setMessages] = createSignal<Message[]>([])
15
- const [streaming, setStreaming] = createSignal(false)
16
- const [streamText, setStreamText] = createSignal("")
17
- const [model, setModel] = createSignal("")
18
- const [modelName, setModelName] = createSignal("")
19
- const [inputBuf, setInputBuf] = createSignal("")
20
-
21
- onMount(async () => {
22
- try {
23
- const { Config } = await import("../../config")
24
- const { AIProvider } = await import("../../ai/provider")
25
- const cfg = await Config.load()
26
- const id = cfg.model || AIProvider.DEFAULT_MODEL
27
- setModel(id)
28
- const info = AIProvider.BUILTIN_MODELS[id]
29
- setModelName(info?.name || id)
30
- } catch {}
31
-
32
- // Auto-send initial message from home screen
33
- const data = route.data as any
34
- if (data.sessionMessages?.length > 0) {
35
- for (const msg of data.sessionMessages) {
36
- if (msg.role === "user") {
37
- send(msg.content)
38
- break
39
- }
40
- }
41
- }
42
- })
43
-
44
- async function send(text: string) {
45
- if (!text.trim() || streaming()) return
46
- const userMsg: Message = { role: "user", content: text.trim() }
47
- const prev = messages()
48
- setMessages([...prev, userMsg])
49
- setStreaming(true)
50
- setStreamText("")
51
-
52
- try {
53
- const { AIChat } = await import("../../ai/chat")
54
- const allMsgs = [...prev, userMsg].map((m) => ({
55
- role: m.role as "user" | "assistant",
56
- content: m.content,
57
- }))
58
-
59
- let full = ""
60
- await AIChat.stream(
61
- allMsgs,
62
- {
63
- onToken: (token) => {
64
- full += token
65
- setStreamText(full)
66
- },
67
- onFinish: (t) => {
68
- setMessages((p) => [...p, { role: "assistant", content: t }])
69
- setStreamText("")
70
- setStreaming(false)
71
- },
72
- onError: (err) => {
73
- setMessages((p) => [...p, { role: "assistant", content: `Error: ${err.message}` }])
74
- setStreamText("")
75
- setStreaming(false)
76
- },
77
- },
78
- model(),
79
- )
80
- } catch (err) {
81
- const msg = err instanceof Error ? err.message : String(err)
82
- setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
83
- setStreamText("")
84
- setStreaming(false)
85
- }
86
- }
87
-
88
- function handleCommand(cmd: string) {
89
- const parts = cmd.split(/\s+/)
90
- const name = parts[0]
91
-
92
- if (name === "/clear") {
93
- setMessages([])
94
- setStreamText("")
95
- return
96
- }
97
-
98
- if (name === "/model") {
99
- const id = parts[1]
100
- if (!id) {
101
- setMessages((p) => [...p, { role: "assistant", content: `Current model: ${modelName()} (${model()})\nUsage: /model <model-id>` }])
102
- return
103
- }
104
- setModel(id)
105
- import("../../ai/provider").then(({ AIProvider }) => {
106
- const info = AIProvider.BUILTIN_MODELS[id]
107
- setModelName(info?.name || id)
108
- }).catch(() => setModelName(id))
109
- setMessages((p) => [...p, { role: "assistant", content: `Switched to model: ${id}` }])
110
- return
111
- }
112
-
113
- if (name === "/help") {
114
- setMessages((p) => [...p, {
115
- role: "assistant",
116
- content: [
117
- "Available commands:",
118
- " /model <id> — switch AI model (e.g. /model gpt-4o)",
119
- " /model — show current model",
120
- " /clear — clear conversation",
121
- " /help — show this help",
122
- "",
123
- "Type any text and press Enter to chat with AI.",
124
- ].join("\n"),
125
- }])
126
- return
127
- }
128
-
129
- setMessages((p) => [...p, { role: "assistant", content: `Unknown command: ${name}. Type /help` }])
130
- }
131
-
132
- useKeyboard((evt) => {
133
- if (evt.name === "return" && !evt.shift) {
134
- const text = inputBuf().trim()
135
- if (!text) return
136
- setInputBuf("")
137
- if (text.startsWith("/")) {
138
- handleCommand(text)
139
- } else {
140
- send(text)
141
- }
142
- evt.preventDefault()
143
- return
144
- }
145
-
146
- if (evt.name === "backspace") {
147
- setInputBuf((s) => s.slice(0, -1))
148
- evt.preventDefault()
149
- return
150
- }
151
-
152
- if (evt.sequence && evt.sequence.length === 1 && !evt.ctrl && !evt.meta) {
153
- setInputBuf((s) => s + evt.sequence)
154
- evt.preventDefault()
155
- return
156
- }
157
-
158
- if (evt.name === "space") {
159
- setInputBuf((s) => s + " ")
160
- evt.preventDefault()
161
- return
162
- }
163
- })
164
-
165
- return (
166
- <box flexDirection="column" flexGrow={1}>
167
- {/* Header */}
168
- <box paddingLeft={2} paddingRight={2} paddingTop={1} flexShrink={0} flexDirection="row" gap={1}>
169
- <text fg={theme.colors.primary}>
170
- <span style={{ bold: true }}>AI Chat</span>
171
- </text>
172
- <text fg={theme.colors.textMuted}>{modelName()}</text>
173
- <box flexGrow={1} />
174
- <text fg={theme.colors.textMuted}>esc:back · /help · /model · /clear</text>
175
- </box>
176
-
177
- {/* Messages */}
178
- <box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
179
- <For each={messages()}>
180
- {(msg) => (
181
- <box flexDirection="row" paddingBottom={1}>
182
- <text fg={msg.role === "user" ? theme.colors.primary : theme.colors.success}>
183
- <span style={{ bold: true }}>{msg.role === "user" ? "❯ " : "◆ "}</span>
184
- </text>
185
- <text fg={theme.colors.text}>{msg.content}</text>
186
- </box>
187
- )}
188
- </For>
189
-
190
- <Show when={streaming()}>
191
- <box flexDirection="row" paddingBottom={1}>
192
- <text fg={theme.colors.success}>
193
- <span style={{ bold: true }}>{"◆ "}</span>
194
- </text>
195
- <text fg={theme.colors.textMuted}>{streamText() || "thinking..."}</text>
196
- </box>
197
- </Show>
198
- </box>
199
-
200
- {/* Input */}
201
- <box paddingLeft={2} paddingRight={2} paddingBottom={1} flexShrink={0} flexDirection="row">
202
- <text fg={theme.colors.primary}>
203
- <span style={{ bold: true }}>{"❯ "}</span>
204
- </text>
205
- <text fg={theme.colors.input}>{inputBuf()}</text>
206
- <text fg={theme.colors.cursor}>{"█"}</text>
207
- </box>
208
- </box>
209
- )
210
- }