codeblog-app 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.
- package/package.json +27 -1
- package/src/ai/chat.ts +92 -0
- package/src/ai/provider.ts +311 -0
- package/src/cli/cmd/ai-publish.ts +95 -0
- package/src/cli/cmd/chat.ts +171 -0
- package/src/cli/cmd/config.ts +103 -0
- package/src/cli/cmd/tui.ts +20 -0
- package/src/index.ts +11 -1
- package/src/tui/app.tsx +109 -0
- package/src/tui/context/exit.tsx +15 -0
- package/src/tui/context/helper.tsx +25 -0
- package/src/tui/context/route.tsx +20 -0
- package/src/tui/routes/chat.tsx +136 -0
- package/src/tui/routes/home.tsx +110 -0
- package/src/tui/routes/search.tsx +104 -0
- package/src/tui/routes/trending.tsx +107 -0
- package/tsconfig.json +2 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { CommandModule } from "yargs"
|
|
2
|
+
import { AIChat } from "../../ai/chat"
|
|
3
|
+
import { AIProvider } from "../../ai/provider"
|
|
4
|
+
import { UI } from "../ui"
|
|
5
|
+
import readline from "readline"
|
|
6
|
+
|
|
7
|
+
export const ChatCommand: CommandModule = {
|
|
8
|
+
command: "chat",
|
|
9
|
+
aliases: ["c"],
|
|
10
|
+
describe: "Interactive AI chat — write posts, analyze code, browse the forum",
|
|
11
|
+
builder: (yargs) =>
|
|
12
|
+
yargs
|
|
13
|
+
.option("model", {
|
|
14
|
+
alias: "m",
|
|
15
|
+
describe: "Model to use (e.g. claude-sonnet-4-20250514, gpt-4o)",
|
|
16
|
+
type: "string",
|
|
17
|
+
})
|
|
18
|
+
.option("prompt", {
|
|
19
|
+
alias: "p",
|
|
20
|
+
describe: "Single prompt (non-interactive mode)",
|
|
21
|
+
type: "string",
|
|
22
|
+
}),
|
|
23
|
+
handler: async (args) => {
|
|
24
|
+
const modelID = args.model as string | undefined
|
|
25
|
+
|
|
26
|
+
// Non-interactive: single prompt
|
|
27
|
+
if (args.prompt) {
|
|
28
|
+
try {
|
|
29
|
+
await AIChat.stream(
|
|
30
|
+
[{ role: "user", content: args.prompt as string }],
|
|
31
|
+
{
|
|
32
|
+
onToken: (token) => process.stdout.write(token),
|
|
33
|
+
onFinish: () => process.stdout.write("\n"),
|
|
34
|
+
onError: (err) => UI.error(err.message),
|
|
35
|
+
},
|
|
36
|
+
modelID,
|
|
37
|
+
)
|
|
38
|
+
} catch (err) {
|
|
39
|
+
UI.error(err instanceof Error ? err.message : String(err))
|
|
40
|
+
process.exitCode = 1
|
|
41
|
+
}
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Interactive REPL
|
|
46
|
+
const modelInfo = AIProvider.BUILTIN_MODELS[modelID || AIProvider.DEFAULT_MODEL]
|
|
47
|
+
const modelName = modelInfo?.name || modelID || AIProvider.DEFAULT_MODEL
|
|
48
|
+
|
|
49
|
+
console.log("")
|
|
50
|
+
console.log(` ${UI.Style.TEXT_HIGHLIGHT_BOLD}CodeBlog AI Chat${UI.Style.TEXT_NORMAL}`)
|
|
51
|
+
console.log(` ${UI.Style.TEXT_DIM}Model: ${modelName}${UI.Style.TEXT_NORMAL}`)
|
|
52
|
+
console.log(` ${UI.Style.TEXT_DIM}Type your message. Commands: /help /model /clear /exit${UI.Style.TEXT_NORMAL}`)
|
|
53
|
+
console.log("")
|
|
54
|
+
|
|
55
|
+
const messages: AIChat.Message[] = []
|
|
56
|
+
const rl = readline.createInterface({
|
|
57
|
+
input: process.stdin,
|
|
58
|
+
output: process.stdout,
|
|
59
|
+
prompt: `${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
let currentModel = modelID
|
|
63
|
+
|
|
64
|
+
rl.prompt()
|
|
65
|
+
|
|
66
|
+
rl.on("line", async (line) => {
|
|
67
|
+
const input = line.trim()
|
|
68
|
+
if (!input) {
|
|
69
|
+
rl.prompt()
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Handle commands
|
|
74
|
+
if (input.startsWith("/")) {
|
|
75
|
+
const cmd = input.split(" ")[0]
|
|
76
|
+
const rest = input.slice(cmd.length).trim()
|
|
77
|
+
|
|
78
|
+
if (cmd === "/exit" || cmd === "/quit" || cmd === "/q") {
|
|
79
|
+
console.log("")
|
|
80
|
+
UI.info("Bye!")
|
|
81
|
+
rl.close()
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (cmd === "/clear") {
|
|
86
|
+
messages.length = 0
|
|
87
|
+
console.log(` ${UI.Style.TEXT_DIM}Chat history cleared${UI.Style.TEXT_NORMAL}`)
|
|
88
|
+
rl.prompt()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (cmd === "/model") {
|
|
93
|
+
if (rest) {
|
|
94
|
+
currentModel = rest
|
|
95
|
+
console.log(` ${UI.Style.TEXT_SUCCESS}Model: ${rest}${UI.Style.TEXT_NORMAL}`)
|
|
96
|
+
} else {
|
|
97
|
+
const current = AIProvider.BUILTIN_MODELS[currentModel || AIProvider.DEFAULT_MODEL]
|
|
98
|
+
console.log(` ${UI.Style.TEXT_DIM}Current: ${current?.name || currentModel || AIProvider.DEFAULT_MODEL}${UI.Style.TEXT_NORMAL}`)
|
|
99
|
+
console.log(` ${UI.Style.TEXT_DIM}Built-in: ${Object.keys(AIProvider.BUILTIN_MODELS).join(", ")}${UI.Style.TEXT_NORMAL}`)
|
|
100
|
+
console.log(` ${UI.Style.TEXT_DIM}Any model from models.dev works too (e.g. anthropic/claude-sonnet-4-20250514)${UI.Style.TEXT_NORMAL}`)
|
|
101
|
+
}
|
|
102
|
+
rl.prompt()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (cmd === "/help") {
|
|
107
|
+
console.log("")
|
|
108
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Commands${UI.Style.TEXT_NORMAL}`)
|
|
109
|
+
console.log(` ${UI.Style.TEXT_DIM}/model [id]${UI.Style.TEXT_NORMAL} Switch or show model`)
|
|
110
|
+
console.log(` ${UI.Style.TEXT_DIM}/clear${UI.Style.TEXT_NORMAL} Clear chat history`)
|
|
111
|
+
console.log(` ${UI.Style.TEXT_DIM}/exit${UI.Style.TEXT_NORMAL} Exit chat`)
|
|
112
|
+
console.log("")
|
|
113
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Tips${UI.Style.TEXT_NORMAL}`)
|
|
114
|
+
console.log(` ${UI.Style.TEXT_DIM}Ask me to write a blog post, analyze code, draft comments,${UI.Style.TEXT_NORMAL}`)
|
|
115
|
+
console.log(` ${UI.Style.TEXT_DIM}summarize discussions, or generate tags and titles.${UI.Style.TEXT_NORMAL}`)
|
|
116
|
+
console.log("")
|
|
117
|
+
rl.prompt()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(` ${UI.Style.TEXT_DIM}Unknown command: ${cmd}. Type /help${UI.Style.TEXT_NORMAL}`)
|
|
122
|
+
rl.prompt()
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Send message to AI
|
|
127
|
+
messages.push({ role: "user", content: input })
|
|
128
|
+
|
|
129
|
+
console.log("")
|
|
130
|
+
process.stdout.write(` ${UI.Style.TEXT_INFO}`)
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
let response = ""
|
|
134
|
+
await AIChat.stream(
|
|
135
|
+
messages,
|
|
136
|
+
{
|
|
137
|
+
onToken: (token) => {
|
|
138
|
+
process.stdout.write(token)
|
|
139
|
+
response += token
|
|
140
|
+
},
|
|
141
|
+
onFinish: () => {
|
|
142
|
+
process.stdout.write(UI.Style.TEXT_NORMAL)
|
|
143
|
+
console.log("")
|
|
144
|
+
console.log("")
|
|
145
|
+
},
|
|
146
|
+
onError: (err) => {
|
|
147
|
+
process.stdout.write(UI.Style.TEXT_NORMAL)
|
|
148
|
+
console.log("")
|
|
149
|
+
UI.error(err.message)
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
currentModel,
|
|
153
|
+
)
|
|
154
|
+
messages.push({ role: "assistant", content: response })
|
|
155
|
+
} catch (err) {
|
|
156
|
+
process.stdout.write(UI.Style.TEXT_NORMAL)
|
|
157
|
+
console.log("")
|
|
158
|
+
UI.error(err instanceof Error ? err.message : String(err))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
rl.prompt()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
rl.on("close", () => {
|
|
165
|
+
process.exit(0)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// Keep process alive
|
|
169
|
+
await new Promise(() => {})
|
|
170
|
+
},
|
|
171
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { CommandModule } from "yargs"
|
|
2
|
+
import { Config } from "../../config"
|
|
3
|
+
import { AIProvider } from "../../ai/provider"
|
|
4
|
+
import { UI } from "../ui"
|
|
5
|
+
|
|
6
|
+
export const ConfigCommand: CommandModule = {
|
|
7
|
+
command: "config",
|
|
8
|
+
describe: "Configure AI provider and model settings",
|
|
9
|
+
builder: (yargs) =>
|
|
10
|
+
yargs
|
|
11
|
+
.option("provider", {
|
|
12
|
+
describe: "AI provider: anthropic, openai, google",
|
|
13
|
+
type: "string",
|
|
14
|
+
})
|
|
15
|
+
.option("api-key", {
|
|
16
|
+
describe: "API key for the provider",
|
|
17
|
+
type: "string",
|
|
18
|
+
})
|
|
19
|
+
.option("model", {
|
|
20
|
+
describe: "Default model ID",
|
|
21
|
+
type: "string",
|
|
22
|
+
})
|
|
23
|
+
.option("list", {
|
|
24
|
+
describe: "List available models and their status",
|
|
25
|
+
type: "boolean",
|
|
26
|
+
default: false,
|
|
27
|
+
}),
|
|
28
|
+
handler: async (args) => {
|
|
29
|
+
try {
|
|
30
|
+
if (args.list) {
|
|
31
|
+
const models = await AIProvider.available()
|
|
32
|
+
const providers = await AIProvider.listProviders()
|
|
33
|
+
|
|
34
|
+
console.log("")
|
|
35
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Providers${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(${Object.keys(providers).length} from models.dev)${UI.Style.TEXT_NORMAL}`)
|
|
36
|
+
console.log("")
|
|
37
|
+
|
|
38
|
+
const configured = Object.entries(providers).filter(([, p]) => p.hasKey)
|
|
39
|
+
const unconfigured = Object.entries(providers).filter(([, p]) => !p.hasKey)
|
|
40
|
+
|
|
41
|
+
if (configured.length > 0) {
|
|
42
|
+
console.log(` ${UI.Style.TEXT_SUCCESS}Configured:${UI.Style.TEXT_NORMAL}`)
|
|
43
|
+
for (const [id, p] of configured) {
|
|
44
|
+
console.log(` ${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_NORMAL_BOLD}${p.name}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(${p.models.length} models)${UI.Style.TEXT_NORMAL}`)
|
|
45
|
+
}
|
|
46
|
+
console.log("")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Built-in Models${UI.Style.TEXT_NORMAL}`)
|
|
50
|
+
console.log("")
|
|
51
|
+
for (const { model, hasKey } of models) {
|
|
52
|
+
const status = hasKey ? `${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL}` : `${UI.Style.TEXT_DIM}✗${UI.Style.TEXT_NORMAL}`
|
|
53
|
+
console.log(` ${status} ${UI.Style.TEXT_NORMAL_BOLD}${model.name}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(${model.id})${UI.Style.TEXT_NORMAL}`)
|
|
54
|
+
console.log(` ${UI.Style.TEXT_DIM}${model.providerID} · ${(model.contextWindow / 1000).toFixed(0)}k context${UI.Style.TEXT_NORMAL}`)
|
|
55
|
+
}
|
|
56
|
+
console.log("")
|
|
57
|
+
console.log(` ${UI.Style.TEXT_DIM}✓ = API key configured, ✗ = needs key${UI.Style.TEXT_NORMAL}`)
|
|
58
|
+
console.log(` ${UI.Style.TEXT_DIM}Set key: codeblog config --provider anthropic --api-key sk-...${UI.Style.TEXT_NORMAL}`)
|
|
59
|
+
console.log(` ${UI.Style.TEXT_DIM}Any model from models.dev can be used with provider/model format${UI.Style.TEXT_NORMAL}`)
|
|
60
|
+
console.log("")
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (args.provider && args.apiKey) {
|
|
65
|
+
const provider = args.provider as string
|
|
66
|
+
const cfg = await Config.load() as Record<string, unknown>
|
|
67
|
+
const providers = (cfg.providers || {}) as Record<string, Record<string, string>>
|
|
68
|
+
providers[provider] = { ...providers[provider], api_key: args.apiKey as string }
|
|
69
|
+
await Config.save({ ...cfg, providers } as unknown as Config.CodeblogConfig)
|
|
70
|
+
UI.success(`${provider} API key saved`)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (args.model) {
|
|
75
|
+
const model = args.model as string
|
|
76
|
+
const cfg = await Config.load() as Record<string, unknown>
|
|
77
|
+
await Config.save({ ...cfg, model } as unknown as Config.CodeblogConfig)
|
|
78
|
+
UI.success(`Default model set to ${model}`)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Show current config
|
|
83
|
+
const cfg = await Config.load() as Record<string, unknown>
|
|
84
|
+
const model = (cfg.model as string) || AIProvider.DEFAULT_MODEL
|
|
85
|
+
const providers = (cfg.providers || {}) as Record<string, Record<string, string>>
|
|
86
|
+
|
|
87
|
+
console.log("")
|
|
88
|
+
console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Current Config${UI.Style.TEXT_NORMAL}`)
|
|
89
|
+
console.log("")
|
|
90
|
+
console.log(` Model: ${UI.Style.TEXT_HIGHLIGHT}${model}${UI.Style.TEXT_NORMAL}`)
|
|
91
|
+
console.log(` API URL: ${cfg.api_url || "https://codeblog.ai"}`)
|
|
92
|
+
console.log("")
|
|
93
|
+
for (const [id, p] of Object.entries(providers)) {
|
|
94
|
+
const masked = p.api_key ? p.api_key.slice(0, 8) + "..." : "not set"
|
|
95
|
+
console.log(` ${id}: ${UI.Style.TEXT_DIM}${masked}${UI.Style.TEXT_NORMAL}`)
|
|
96
|
+
}
|
|
97
|
+
console.log("")
|
|
98
|
+
} catch (err) {
|
|
99
|
+
UI.error(`Config failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
100
|
+
process.exitCode = 1
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CommandModule } from "yargs"
|
|
2
|
+
|
|
3
|
+
export const TuiCommand: CommandModule = {
|
|
4
|
+
command: "tui",
|
|
5
|
+
aliases: ["ui"],
|
|
6
|
+
describe: "Launch interactive TUI — browse feed, chat with AI, manage posts",
|
|
7
|
+
builder: (yargs) =>
|
|
8
|
+
yargs
|
|
9
|
+
.option("model", {
|
|
10
|
+
alias: "m",
|
|
11
|
+
describe: "Default AI model",
|
|
12
|
+
type: "string",
|
|
13
|
+
}),
|
|
14
|
+
handler: async (args) => {
|
|
15
|
+
const { tui } = await import("../../tui/app")
|
|
16
|
+
await tui({
|
|
17
|
+
onExit: async () => {},
|
|
18
|
+
})
|
|
19
|
+
},
|
|
20
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,8 +27,12 @@ import { FollowCommand } from "./cli/cmd/follow"
|
|
|
27
27
|
import { MyPostsCommand } from "./cli/cmd/myposts"
|
|
28
28
|
import { EditCommand } from "./cli/cmd/edit"
|
|
29
29
|
import { DeleteCommand } from "./cli/cmd/delete"
|
|
30
|
+
import { ChatCommand } from "./cli/cmd/chat"
|
|
31
|
+
import { ConfigCommand } from "./cli/cmd/config"
|
|
32
|
+
import { AIPublishCommand } from "./cli/cmd/ai-publish"
|
|
33
|
+
import { TuiCommand } from "./cli/cmd/tui"
|
|
30
34
|
|
|
31
|
-
const VERSION = "0.
|
|
35
|
+
const VERSION = "0.4.0"
|
|
32
36
|
|
|
33
37
|
process.on("unhandledRejection", (e) => {
|
|
34
38
|
Log.Default.error("rejection", {
|
|
@@ -93,6 +97,12 @@ const cli = yargs(hideBin(process.argv))
|
|
|
93
97
|
// Scan & Publish
|
|
94
98
|
.command(ScanCommand)
|
|
95
99
|
.command(PublishCommand)
|
|
100
|
+
.command(AIPublishCommand)
|
|
101
|
+
// AI
|
|
102
|
+
.command(ChatCommand)
|
|
103
|
+
.command(ConfigCommand)
|
|
104
|
+
// TUI
|
|
105
|
+
.command(TuiCommand)
|
|
96
106
|
// Account
|
|
97
107
|
.command(NotificationsCommand)
|
|
98
108
|
.command(DashboardCommand)
|
package/src/tui/app.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|
2
|
+
import { Switch, Match, onMount } from "solid-js"
|
|
3
|
+
import { RouteProvider, useRoute } from "./context/route"
|
|
4
|
+
import { ExitProvider, useExit } from "./context/exit"
|
|
5
|
+
import { Home } from "./routes/home"
|
|
6
|
+
import { Chat } from "./routes/chat"
|
|
7
|
+
import { Trending } from "./routes/trending"
|
|
8
|
+
import { Search } from "./routes/search"
|
|
9
|
+
|
|
10
|
+
export function tui(input: { onExit?: () => Promise<void> }) {
|
|
11
|
+
return new Promise<void>(async (resolve) => {
|
|
12
|
+
render(
|
|
13
|
+
() => (
|
|
14
|
+
<ExitProvider onExit={async () => { await input.onExit?.(); resolve() }}>
|
|
15
|
+
<RouteProvider>
|
|
16
|
+
<App />
|
|
17
|
+
</RouteProvider>
|
|
18
|
+
</ExitProvider>
|
|
19
|
+
),
|
|
20
|
+
{
|
|
21
|
+
targetFps: 30,
|
|
22
|
+
exitOnCtrlC: false,
|
|
23
|
+
autoFocus: false,
|
|
24
|
+
openConsoleOnError: false,
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function App() {
|
|
31
|
+
const route = useRoute()
|
|
32
|
+
const exit = useExit()
|
|
33
|
+
const dimensions = useTerminalDimensions()
|
|
34
|
+
const renderer = useRenderer()
|
|
35
|
+
|
|
36
|
+
onMount(() => {
|
|
37
|
+
renderer.setTerminalTitle("CodeBlog")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
useKeyboard((evt) => {
|
|
41
|
+
if (evt.ctrl && evt.name === "c") {
|
|
42
|
+
exit()
|
|
43
|
+
evt.preventDefault()
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (evt.name === "q" && !evt.ctrl && route.data.type === "home") {
|
|
48
|
+
exit()
|
|
49
|
+
evt.preventDefault()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (evt.name === "c" && route.data.type === "home") {
|
|
54
|
+
route.navigate({ type: "chat" })
|
|
55
|
+
evt.preventDefault()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (evt.name === "t" && route.data.type === "home") {
|
|
60
|
+
route.navigate({ type: "search", query: "" })
|
|
61
|
+
// reuse search route as trending for now
|
|
62
|
+
route.navigate({ type: "search", query: "__trending__" })
|
|
63
|
+
evt.preventDefault()
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (evt.name === "s" && route.data.type === "home") {
|
|
68
|
+
route.navigate({ type: "search", query: "" })
|
|
69
|
+
evt.preventDefault()
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (evt.name === "escape" && route.data.type !== "home") {
|
|
74
|
+
route.navigate({ type: "home" })
|
|
75
|
+
evt.preventDefault()
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<box flexDirection="column" width="100%" height="100%">
|
|
82
|
+
<Switch>
|
|
83
|
+
<Match when={route.data.type === "home"}>
|
|
84
|
+
<Home />
|
|
85
|
+
</Match>
|
|
86
|
+
<Match when={route.data.type === "chat"}>
|
|
87
|
+
<Chat />
|
|
88
|
+
</Match>
|
|
89
|
+
<Match when={route.data.type === "search" && (route.data as any).query === "__trending__"}>
|
|
90
|
+
<Trending />
|
|
91
|
+
</Match>
|
|
92
|
+
<Match when={route.data.type === "search"}>
|
|
93
|
+
<Search />
|
|
94
|
+
</Match>
|
|
95
|
+
</Switch>
|
|
96
|
+
|
|
97
|
+
{/* Status bar */}
|
|
98
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexShrink={0} flexDirection="row">
|
|
99
|
+
<text fg="#6a737c">
|
|
100
|
+
{route.data.type === "home"
|
|
101
|
+
? "c:chat s:search t:trending q:quit"
|
|
102
|
+
: "esc:back ctrl+c:exit"}
|
|
103
|
+
</text>
|
|
104
|
+
<box flexGrow={1} />
|
|
105
|
+
<text fg="#6a737c">codeblog v0.4.0</text>
|
|
106
|
+
</box>
|
|
107
|
+
</box>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useRenderer } from "@opentui/solid"
|
|
2
|
+
import { createSimpleContext } from "./helper"
|
|
3
|
+
|
|
4
|
+
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|
5
|
+
name: "Exit",
|
|
6
|
+
init: (input: { onExit?: () => Promise<void> }) => {
|
|
7
|
+
const renderer = useRenderer()
|
|
8
|
+
return async () => {
|
|
9
|
+
renderer.setTerminalTitle("")
|
|
10
|
+
renderer.destroy()
|
|
11
|
+
await input.onExit?.()
|
|
12
|
+
process.exit(0)
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
|
2
|
+
|
|
3
|
+
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
|
4
|
+
name: string
|
|
5
|
+
init: ((input: Props) => T) | (() => T)
|
|
6
|
+
}) {
|
|
7
|
+
const ctx = createContext<T>()
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
provider: (props: ParentProps<Props>) => {
|
|
11
|
+
const init = input.init(props)
|
|
12
|
+
return (
|
|
13
|
+
// @ts-expect-error
|
|
14
|
+
<Show when={init.ready === undefined || init.ready === true}>
|
|
15
|
+
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
|
16
|
+
</Show>
|
|
17
|
+
)
|
|
18
|
+
},
|
|
19
|
+
use() {
|
|
20
|
+
const value = useContext(ctx)
|
|
21
|
+
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
|
22
|
+
return value
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createStore } from "solid-js/store"
|
|
2
|
+
import { createSimpleContext } from "./helper"
|
|
3
|
+
|
|
4
|
+
export type HomeRoute = { type: "home" }
|
|
5
|
+
export type ChatRoute = { type: "chat"; sessionMessages?: Array<{ role: string; content: string }> }
|
|
6
|
+
export type PostRoute = { type: "post"; postId: string }
|
|
7
|
+
export type SearchRoute = { type: "search"; query: string }
|
|
8
|
+
|
|
9
|
+
export type Route = HomeRoute | ChatRoute | PostRoute | SearchRoute
|
|
10
|
+
|
|
11
|
+
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
|
12
|
+
name: "Route",
|
|
13
|
+
init: () => {
|
|
14
|
+
const [store, setStore] = createStore<Route>({ type: "home" })
|
|
15
|
+
return {
|
|
16
|
+
get data() { return store },
|
|
17
|
+
navigate(route: Route) { setStore(route) },
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createSignal, For, Show } from "solid-js"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
import { useRoute } from "../context/route"
|
|
4
|
+
|
|
5
|
+
interface Message {
|
|
6
|
+
role: "user" | "assistant"
|
|
7
|
+
content: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Chat() {
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
const [messages, setMessages] = createSignal<Message[]>([])
|
|
13
|
+
const [streaming, setStreaming] = createSignal(false)
|
|
14
|
+
const [streamText, setStreamText] = createSignal("")
|
|
15
|
+
const [model, setModel] = createSignal("claude-sonnet-4-20250514")
|
|
16
|
+
const [inputBuf, setInputBuf] = createSignal("")
|
|
17
|
+
const [inputMode, setInputMode] = createSignal(true)
|
|
18
|
+
|
|
19
|
+
async function send(text: string) {
|
|
20
|
+
if (!text.trim()) return
|
|
21
|
+
const userMsg: Message = { role: "user", content: text.trim() }
|
|
22
|
+
const prev = messages()
|
|
23
|
+
setMessages([...prev, userMsg])
|
|
24
|
+
setStreaming(true)
|
|
25
|
+
setStreamText("")
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { AIChat } = await import("../../ai/chat")
|
|
29
|
+
const allMsgs = [...prev, userMsg].map((m) => ({
|
|
30
|
+
role: m.role as "user" | "assistant",
|
|
31
|
+
content: m.content,
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
let full = ""
|
|
35
|
+
await AIChat.stream(
|
|
36
|
+
allMsgs,
|
|
37
|
+
{
|
|
38
|
+
onToken: (token) => {
|
|
39
|
+
full += token
|
|
40
|
+
setStreamText(full)
|
|
41
|
+
},
|
|
42
|
+
onFinish: (t) => {
|
|
43
|
+
setMessages((p) => [...p, { role: "assistant", content: t }])
|
|
44
|
+
setStreamText("")
|
|
45
|
+
setStreaming(false)
|
|
46
|
+
},
|
|
47
|
+
onError: (err) => {
|
|
48
|
+
setMessages((p) => [...p, { role: "assistant", content: `Error: ${err.message}` }])
|
|
49
|
+
setStreaming(false)
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
model(),
|
|
53
|
+
)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
56
|
+
setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
|
|
57
|
+
setStreaming(false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
useKeyboard((evt) => {
|
|
62
|
+
if (!inputMode()) return
|
|
63
|
+
|
|
64
|
+
if (evt.name === "return" && !evt.shift) {
|
|
65
|
+
const text = inputBuf()
|
|
66
|
+
setInputBuf("")
|
|
67
|
+
send(text)
|
|
68
|
+
evt.preventDefault()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (evt.name === "backspace") {
|
|
73
|
+
setInputBuf((s) => s.slice(0, -1))
|
|
74
|
+
evt.preventDefault()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (evt.sequence && evt.sequence.length === 1 && !evt.ctrl && !evt.meta) {
|
|
79
|
+
setInputBuf((s) => s + evt.sequence)
|
|
80
|
+
evt.preventDefault()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (evt.name === "space") {
|
|
85
|
+
setInputBuf((s) => s + " ")
|
|
86
|
+
evt.preventDefault()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<box flexDirection="column" flexGrow={1}>
|
|
93
|
+
{/* Header */}
|
|
94
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexShrink={0} flexDirection="row" gap={1}>
|
|
95
|
+
<text fg="#d946ef">
|
|
96
|
+
<span style={{ bold: true }}>AI Chat</span>
|
|
97
|
+
</text>
|
|
98
|
+
<text fg="#6a737c">{model()}</text>
|
|
99
|
+
<box flexGrow={1} />
|
|
100
|
+
<text fg="#6a737c">esc:back</text>
|
|
101
|
+
</box>
|
|
102
|
+
|
|
103
|
+
{/* Messages */}
|
|
104
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
|
|
105
|
+
<For each={messages()}>
|
|
106
|
+
{(msg) => (
|
|
107
|
+
<box flexDirection="row" paddingBottom={1}>
|
|
108
|
+
<text fg={msg.role === "user" ? "#0074cc" : "#48a868"}>
|
|
109
|
+
<span style={{ bold: true }}>{msg.role === "user" ? "❯ " : "◆ "}</span>
|
|
110
|
+
</text>
|
|
111
|
+
<text fg="#e7e9eb">{msg.content}</text>
|
|
112
|
+
</box>
|
|
113
|
+
)}
|
|
114
|
+
</For>
|
|
115
|
+
|
|
116
|
+
<Show when={streaming()}>
|
|
117
|
+
<box flexDirection="row" paddingBottom={1}>
|
|
118
|
+
<text fg="#48a868">
|
|
119
|
+
<span style={{ bold: true }}>{"◆ "}</span>
|
|
120
|
+
</text>
|
|
121
|
+
<text fg="#a0a0a0">{streamText() || "thinking..."}</text>
|
|
122
|
+
</box>
|
|
123
|
+
</Show>
|
|
124
|
+
</box>
|
|
125
|
+
|
|
126
|
+
{/* Input */}
|
|
127
|
+
<box paddingLeft={2} paddingRight={2} paddingBottom={1} flexShrink={0} flexDirection="row">
|
|
128
|
+
<text fg="#0074cc">
|
|
129
|
+
<span style={{ bold: true }}>{"❯ "}</span>
|
|
130
|
+
</text>
|
|
131
|
+
<text fg="#e7e9eb">{inputBuf()}</text>
|
|
132
|
+
<text fg="#6a737c">{"█"}</text>
|
|
133
|
+
</box>
|
|
134
|
+
</box>
|
|
135
|
+
)
|
|
136
|
+
}
|