@opentiny/tiny-robot-cli 0.4.1-alpha.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.
Files changed (41) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +21 -0
  3. package/bin/cli.js +26 -0
  4. package/bin/commands/add.js +436 -0
  5. package/bin/commands/create.js +144 -0
  6. package/bin/utils.js +337 -0
  7. package/package.json +28 -0
  8. package/templates/basic/.env.example +2 -0
  9. package/templates/basic/README.md +45 -0
  10. package/templates/basic/index.html +13 -0
  11. package/templates/basic/package.json +29 -0
  12. package/templates/basic/public/favicon.ico +0 -0
  13. package/templates/basic/public/modelcontextprotocol.png +0 -0
  14. package/templates/basic/src/App.vue +130 -0
  15. package/templates/basic/src/components/ChatList.vue +82 -0
  16. package/templates/basic/src/components/ChatSender.vue +125 -0
  17. package/templates/basic/src/components/ConversationHistory.vue +136 -0
  18. package/templates/basic/src/components/HistoryDrawerButton.vue +43 -0
  19. package/templates/basic/src/components/McpServerPickerButton.vue +278 -0
  20. package/templates/basic/src/components/ThemeToggleButton.vue +44 -0
  21. package/templates/basic/src/components/icons/IconDeepThink.vue +29 -0
  22. package/templates/basic/src/components/icons/IconModelAliyunBailian.vue +51 -0
  23. package/templates/basic/src/components/icons/IconModelDeepseek.vue +29 -0
  24. package/templates/basic/src/components/icons/IconMoon.vue +29 -0
  25. package/templates/basic/src/components/icons/IconPlugin.vue +29 -0
  26. package/templates/basic/src/components/icons/IconSun.vue +35 -0
  27. package/templates/basic/src/components/icons/IconWebSearch.vue +36 -0
  28. package/templates/basic/src/components/icons/index.ts +7 -0
  29. package/templates/basic/src/composables/useChat.ts +129 -0
  30. package/templates/basic/src/composables/useMcp.ts +170 -0
  31. package/templates/basic/src/composables/useModel.ts +82 -0
  32. package/templates/basic/src/main.ts +7 -0
  33. package/templates/basic/src/mcpServers.ts +40 -0
  34. package/templates/basic/src/models.ts +81 -0
  35. package/templates/basic/src/style.css +21 -0
  36. package/templates/basic/tsconfig.app.json +16 -0
  37. package/templates/basic/tsconfig.json +7 -0
  38. package/templates/basic/tsconfig.node.json +26 -0
  39. package/templates/basic/vite.config.ts +16 -0
  40. package/templates/chat/.env.example +1 -0
  41. package/templates/chat/src/TinyRobotChat.vue +35 -0
package/bin/utils.js ADDED
@@ -0,0 +1,337 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import pc from 'picocolors'
5
+
6
+ const TEMPLATE_PLACEHOLDER = '__PROJECT_NAME__'
7
+
8
+ export const BUILTIN_TEMPLATES = ['basic']
9
+ export const DEFAULT_TEMPLATE = 'basic'
10
+ export const DEFAULT_PROJECT_NAME = 'tiny-robot-app'
11
+
12
+ const WORKSPACE_FILES = ['pnpm-workspace.yaml', 'pnpm-workspace.yml']
13
+
14
+ const IGNORE_COPY_FILES = ['node_modules', '.git', 'dist', '.DS_Store', '.vite']
15
+
16
+ const __filename = fileURLToPath(import.meta.url)
17
+ const __dirname = path.dirname(__filename)
18
+
19
+ const templatesRoot = path.resolve(__dirname, '../templates')
20
+
21
+ function createStatusLabel(icon, label, color) {
22
+ return `${color(icon)} ${color(label)}`
23
+ }
24
+
25
+ export function logSuccess(message) {
26
+ console.log(`${createStatusLabel('✔', 'SUCCESS', pc.green)} ${message}`)
27
+ }
28
+
29
+ export function logSkip(message) {
30
+ console.log(`${createStatusLabel('○', 'SKIPPED', pc.dim)} ${message}`)
31
+ }
32
+
33
+ export function logError(message) {
34
+ console.log(`${createStatusLabel('✖', 'FAILED', pc.red)} ${message}`)
35
+ }
36
+
37
+ export function invariant(condition, message) {
38
+ if (!condition) {
39
+ console.error(`Error: ${message}`)
40
+ process.exit(1)
41
+ }
42
+ }
43
+
44
+ export function exists(file) {
45
+ return fs.existsSync(file)
46
+ }
47
+
48
+ function isPackageDir(dir) {
49
+ return exists(path.join(dir, 'package.json'))
50
+ }
51
+
52
+ function findUp(startDir, matcher) {
53
+ let dir = path.resolve(startDir)
54
+
55
+ for (;;) {
56
+ if (matcher(dir)) {
57
+ return dir
58
+ }
59
+
60
+ const parent = path.dirname(dir)
61
+
62
+ if (parent === dir) {
63
+ return null
64
+ }
65
+
66
+ dir = parent
67
+ }
68
+ }
69
+
70
+ export function getAvailableTemplates() {
71
+ if (!exists(templatesRoot)) {
72
+ return []
73
+ }
74
+
75
+ return fs
76
+ .readdirSync(templatesRoot, { withFileTypes: true })
77
+ .filter((entry) => entry.isDirectory() && BUILTIN_TEMPLATES.includes(entry.name))
78
+ .map((entry) => entry.name)
79
+ }
80
+
81
+ export function getTemplateDir(templateName) {
82
+ return path.join(templatesRoot, templateName)
83
+ }
84
+
85
+ export function validateProjectName(name) {
86
+ return /^[a-z0-9-]+$/.test(name)
87
+ }
88
+
89
+ export function copyTemplate(sourceDir, targetDir) {
90
+ fs.cpSync(sourceDir, targetDir, {
91
+ recursive: true,
92
+ filter: (source) => {
93
+ return !IGNORE_COPY_FILES.includes(path.basename(source))
94
+ },
95
+ })
96
+ }
97
+
98
+ function renameSpecialFiles(targetDir) {
99
+ const files = [['_gitignore', '.gitignore']]
100
+
101
+ for (const [fromName, toName] of files) {
102
+ const from = path.join(targetDir, fromName)
103
+ const to = path.join(targetDir, toName)
104
+
105
+ if (exists(from)) {
106
+ fs.renameSync(from, to)
107
+ }
108
+ }
109
+ }
110
+
111
+ function replaceTemplateVariables(targetDir, variables) {
112
+ const replaceFiles = ['package.json', 'README.md']
113
+
114
+ for (const relativePath of replaceFiles) {
115
+ const file = path.join(targetDir, relativePath)
116
+
117
+ if (!exists(file)) {
118
+ continue
119
+ }
120
+
121
+ let content = fs.readFileSync(file, 'utf-8')
122
+
123
+ for (const [key, value] of Object.entries(variables)) {
124
+ content = content.replaceAll(key, value)
125
+ }
126
+
127
+ fs.writeFileSync(file, content, 'utf-8')
128
+ }
129
+ }
130
+
131
+ export function scaffoldProject(templateDir, targetDir, projectName) {
132
+ copyTemplate(templateDir, targetDir)
133
+
134
+ renameSpecialFiles(targetDir)
135
+
136
+ replaceTemplateVariables(targetDir, {
137
+ [TEMPLATE_PLACEHOLDER]: projectName,
138
+ })
139
+ }
140
+
141
+ function resolveWorkspaceFile(workspaceRoot) {
142
+ for (const name of WORKSPACE_FILES) {
143
+ const file = path.join(workspaceRoot, name)
144
+
145
+ if (exists(file)) {
146
+ return file
147
+ }
148
+ }
149
+
150
+ return null
151
+ }
152
+
153
+ export function findWorkspaceRoot(cwd) {
154
+ return findUp(cwd, (dir) => {
155
+ return WORKSPACE_FILES.some((name) => {
156
+ return exists(path.join(dir, name))
157
+ })
158
+ })
159
+ }
160
+
161
+ export function findProjectRoot(cwd) {
162
+ return findUp(cwd, (dir) => {
163
+ return isPackageDir(dir)
164
+ })
165
+ }
166
+
167
+ export function findSubPackageRoot(cwd, workspaceRoot) {
168
+ return findUp(cwd, (dir) => {
169
+ if (dir === workspaceRoot) {
170
+ return false
171
+ }
172
+
173
+ return isPackageDir(dir)
174
+ })
175
+ }
176
+
177
+ export function findWorkspacePackages(workspaceRoot) {
178
+ const workspaceFile = resolveWorkspaceFile(workspaceRoot)
179
+
180
+ if (!workspaceFile) {
181
+ return []
182
+ }
183
+
184
+ const content = fs.readFileSync(workspaceFile, 'utf-8')
185
+
186
+ const packages = []
187
+
188
+ let inPackages = false
189
+
190
+ for (const line of content.split('\n')) {
191
+ const trimmed = line.trim()
192
+
193
+ if (trimmed === 'packages:') {
194
+ inPackages = true
195
+ continue
196
+ }
197
+
198
+ if (!inPackages) {
199
+ continue
200
+ }
201
+
202
+ const match = trimmed.match(/^-\s+(.+)$/)
203
+
204
+ if (match) {
205
+ const pattern = match[1]
206
+
207
+ if (!pattern.startsWith('!')) {
208
+ packages.push(pattern)
209
+ }
210
+
211
+ continue
212
+ }
213
+
214
+ if (trimmed && !trimmed.startsWith('#')) {
215
+ break
216
+ }
217
+ }
218
+
219
+ return packages
220
+ }
221
+
222
+ function normalizeWorkspacePattern(pattern) {
223
+ return pattern.replace(/\*\*?/g, '').replace(/\/$/, '')
224
+ }
225
+
226
+ export function listPackages(workspaceRoot, patterns) {
227
+ const packageDirs = []
228
+
229
+ const addPackage = (dir) => {
230
+ if (isPackageDir(dir)) {
231
+ packageDirs.push(dir)
232
+ }
233
+ }
234
+
235
+ for (const pattern of patterns) {
236
+ const base = normalizeWorkspacePattern(pattern)
237
+
238
+ const fullPath = path.join(workspaceRoot, base)
239
+
240
+ if (!exists(fullPath)) {
241
+ continue
242
+ }
243
+
244
+ if (pattern.includes('*')) {
245
+ const entries = fs.readdirSync(fullPath, {
246
+ withFileTypes: true,
247
+ })
248
+
249
+ for (const entry of entries) {
250
+ if (entry.isDirectory()) {
251
+ addPackage(path.join(fullPath, entry.name))
252
+ }
253
+ }
254
+ } else {
255
+ addPackage(fullPath)
256
+ }
257
+ }
258
+
259
+ return packageDirs
260
+ }
261
+
262
+ function ensureDir(file) {
263
+ fs.mkdirSync(path.dirname(file), {
264
+ recursive: true,
265
+ })
266
+ }
267
+
268
+ export function copyFile(from, to) {
269
+ ensureDir(to)
270
+
271
+ fs.copyFileSync(from, to)
272
+ }
273
+
274
+ function parseEnv(content) {
275
+ const map = new Map()
276
+
277
+ for (const line of content.split('\n')) {
278
+ const trimmed = line.trim()
279
+
280
+ if (!trimmed || trimmed.startsWith('#')) {
281
+ continue
282
+ }
283
+
284
+ const index = trimmed.indexOf('=')
285
+
286
+ if (index === -1) {
287
+ continue
288
+ }
289
+
290
+ const key = trimmed.slice(0, index).trim()
291
+
292
+ map.set(key, trimmed)
293
+ }
294
+
295
+ return map
296
+ }
297
+
298
+ export function mergeEnvFile(templateFile, targetFile) {
299
+ if (!fs.existsSync(targetFile)) {
300
+ copyFile(templateFile, targetFile)
301
+
302
+ return {
303
+ type: 'created',
304
+ }
305
+ }
306
+
307
+ const templateContent = fs.readFileSync(templateFile, 'utf-8')
308
+
309
+ const targetContent = fs.readFileSync(targetFile, 'utf-8')
310
+
311
+ const templateEnv = parseEnv(templateContent)
312
+
313
+ const targetEnv = parseEnv(targetContent)
314
+
315
+ const appendLines = []
316
+
317
+ for (const [key, line] of templateEnv) {
318
+ if (!targetEnv.has(key)) {
319
+ appendLines.push(line)
320
+ }
321
+ }
322
+
323
+ if (appendLines.length === 0) {
324
+ return {
325
+ type: 'skipped',
326
+ }
327
+ }
328
+
329
+ const nextContent = [targetContent.trimEnd(), '', ...appendLines, ''].join('\n')
330
+
331
+ fs.writeFileSync(targetFile, nextContent)
332
+
333
+ return {
334
+ type: 'merged',
335
+ added: appendLines.length,
336
+ }
337
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@opentiny/tiny-robot-cli",
3
+ "version": "0.4.1-alpha.0",
4
+ "description": "CLI to scaffold TinyRobot product projects",
5
+ "type": "module",
6
+ "bin": {
7
+ "tiny-robot-cli": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "templates",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "node ./bin/cli.js"
16
+ },
17
+ "license": "MIT",
18
+ "engines": {
19
+ "node": ">=20.13.0"
20
+ },
21
+ "dependencies": {
22
+ "@inquirer/prompts": "^8.3.2",
23
+ "commander": "^14.0.3",
24
+ "picocolors": "^1.1.1",
25
+ "semver": "^7.8.1"
26
+ },
27
+ "gitHead": "22cb4503b47f2e1d2134d4a90d02b5643672ef6c"
28
+ }
@@ -0,0 +1,2 @@
1
+ VITE_ALIYUN_DASHSCOPE_KEY=
2
+ VITE_DEEPSEEK_API_KEY=
@@ -0,0 +1,45 @@
1
+ # __PROJECT_NAME__
2
+
3
+ TinyRobot AI chat starter built with Vue 3 + Vite.
4
+
5
+ ## Features
6
+
7
+ - Vue 3 + Vite + TypeScript project scaffold
8
+ - TinyRobot chat UI with `TrBubbleList`, `TrSender`, and markdown rendering
9
+ - Conversation management via `useConversation`
10
+ - Model switch with thinking/search capability toggles
11
+ - MCP server picker for add/toggle/delete server usage
12
+ - MCP transport support for both `sse` and `streamableHttp`
13
+ - Tool calling pipeline through `toolPlugin` + MCP `listTools` / `callTool`
14
+ - Theme toggle and responsive layout for desktop/mobile
15
+
16
+ ## Setup
17
+
18
+ 1. Copy environment variables:
19
+
20
+ ```bash
21
+ cp .env.example .env
22
+ ```
23
+
24
+ 2. Fill your provider keys in `.env`:
25
+
26
+ ```env
27
+ VITE_ALIYUN_DASHSCOPE_KEY=your_dashscope_key
28
+ VITE_DEEPSEEK_API_KEY=your_deepseek_key
29
+ ```
30
+
31
+ `VITE_ALIYUN_DASHSCOPE_KEY` is also used by configured MCP servers that require DashScope authorization.
32
+
33
+ ## Development
34
+
35
+ ```bash
36
+ pnpm install
37
+ pnpm dev
38
+ ```
39
+
40
+ ## Build
41
+
42
+ ```bash
43
+ pnpm build
44
+ pnpm preview
45
+ ```
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>TinyRobot AI Chat</title>
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.ts"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vue-tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "type-check": "vue-tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.18.1",
14
+ "@opentiny/tiny-robot": "^0.4.1",
15
+ "@opentiny/tiny-robot-kit": "^0.4.1",
16
+ "@opentiny/tiny-robot-svgs": "^0.4.1",
17
+ "dompurify": "^3.3.1",
18
+ "markdown-it": "^14.1.0",
19
+ "vue": "^3.5.30"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^24.12.0",
23
+ "@vitejs/plugin-vue": "^6.0.5",
24
+ "@vue/tsconfig": "^0.9.0",
25
+ "typescript": "~5.9.3",
26
+ "vite": "^8.0.1",
27
+ "vue-tsc": "^3.2.5"
28
+ }
29
+ }
@@ -0,0 +1,130 @@
1
+ <template>
2
+ <TrTheme>
3
+ <main class="app chat-card">
4
+ <ConversationHistory />
5
+
6
+ <section class="chat-panel">
7
+ <div class="chat-panel-content">
8
+ <p v-if="!hasApiConfig" class="config-warning">
9
+ 缺少 API 配置,请在 <code>.env</code> 中设置当前模型服务商对应的 Key。
10
+ </p>
11
+ <header class="chat-header">
12
+ <HistoryDrawerButton @click="historyDrawerOpen = true" />
13
+ <h3 v-if="currentConversationTitle">{{ currentConversationTitle }}</h3>
14
+ <ThemeToggleButton v-if="isWelcomePage" />
15
+ </header>
16
+
17
+ <ChatList />
18
+
19
+ <ChatSender />
20
+ </div>
21
+ </section>
22
+ </main>
23
+ </TrTheme>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ import { TrThemeProvider as TrTheme } from '@opentiny/tiny-robot'
28
+ import { computed, provide, ref } from 'vue'
29
+ import ChatList from './components/ChatList.vue'
30
+ import ChatSender from './components/ChatSender.vue'
31
+ import ConversationHistory from './components/ConversationHistory.vue'
32
+ import HistoryDrawerButton from './components/HistoryDrawerButton.vue'
33
+ import ThemeToggleButton from './components/ThemeToggleButton.vue'
34
+ import { useChat } from './composables/useChat'
35
+ import { useModel } from './composables/useModel'
36
+
37
+ const { activeConversation, messages } = useChat()
38
+ const { hasApiConfig } = useModel()
39
+ const historyDrawerOpen = ref(false)
40
+ provide('historyDrawerOpen', historyDrawerOpen)
41
+
42
+ const isWelcomePage = computed(() => {
43
+ return messages.value.filter((item) => item.role !== 'system').length === 0
44
+ })
45
+
46
+ const currentConversationTitle = computed(() => {
47
+ return activeConversation.value?.title
48
+ })
49
+ </script>
50
+
51
+ <style scoped>
52
+ .app {
53
+ height: 100vh;
54
+ display: flex;
55
+ justify-content: center;
56
+ align-items: stretch;
57
+ padding: 0;
58
+ }
59
+
60
+ .chat-card {
61
+ width: 100%;
62
+ height: 100vh;
63
+ display: flex;
64
+ gap: 0;
65
+ overflow: hidden;
66
+ align-items: stretch;
67
+ flex-direction: row;
68
+ padding: 0;
69
+ margin: 0;
70
+ border-radius: 0;
71
+ background: var(--tr-container-bg-default);
72
+ box-shadow: none;
73
+ }
74
+
75
+ .chat-panel {
76
+ flex: 1;
77
+ min-width: 0;
78
+ display: flex;
79
+ padding: 12px;
80
+ }
81
+
82
+ .chat-panel-content {
83
+ width: 100%;
84
+ max-width: 980px;
85
+ margin: 0 auto;
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: 12px;
89
+ min-height: 0;
90
+ }
91
+
92
+ .chat-header h3 {
93
+ margin: 0;
94
+ flex: 1;
95
+ min-width: 0;
96
+ white-space: nowrap;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ }
100
+
101
+ .chat-header {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 8px;
105
+ }
106
+
107
+ .config-warning {
108
+ margin: 0;
109
+ padding: 10px 12px;
110
+ border-radius: 8px;
111
+ background: var(--tr-color-warning-light);
112
+ color: var(--tr-color-warning);
113
+ font-size: 14px;
114
+ }
115
+
116
+ @media (max-width: 959px) {
117
+ .chat-header {
118
+ position: relative;
119
+ justify-content: center;
120
+ min-height: 32px;
121
+ }
122
+
123
+ .chat-header h3 {
124
+ width: 100%;
125
+ text-align: center;
126
+ padding: 0 48px;
127
+ flex: none;
128
+ }
129
+ }
130
+ </style>
@@ -0,0 +1,82 @@
1
+ <template>
2
+ <tr-bubble-provider :fallback-content-renderer="BubbleRenderers.Markdown">
3
+ <tr-welcome
4
+ v-if="visibleMessages.length === 0"
5
+ title="TinyRobot AI 助手"
6
+ description="您好,我是TinyRobot,您专属的 AI 智能专家"
7
+ :icon="welcomeIcon"
8
+ class="chat-list chat-welcome"
9
+ />
10
+ <tr-bubble-list
11
+ v-else
12
+ :messages="messages"
13
+ :role-configs="roles"
14
+ :auto-scroll="true"
15
+ class="chat-list"
16
+ ></tr-bubble-list>
17
+ </tr-bubble-provider>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { BubbleRenderers, TrBubbleList, TrBubbleProvider, TrWelcome, type BubbleRoleConfig } from '@opentiny/tiny-robot'
22
+ import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs'
23
+ import { computed, h } from 'vue'
24
+ import { useChat } from '../composables/useChat'
25
+
26
+ const { messages } = useChat()
27
+
28
+ const aiAvatar = h(IconAi, { style: { fontSize: '28px' } })
29
+ const userAvatar = h(IconUser, { style: { fontSize: '28px' } })
30
+ const welcomeIcon = h(IconAi, { style: { fontSize: '40px' } })
31
+ const visibleMessages = computed(() => messages.value.filter((item) => item.role !== 'system'))
32
+
33
+ const roles: Record<string, BubbleRoleConfig> = {
34
+ assistant: {
35
+ placement: 'start',
36
+ avatar: aiAvatar,
37
+ },
38
+ user: {
39
+ placement: 'end',
40
+ avatar: userAvatar,
41
+ },
42
+ system: {
43
+ hidden: true,
44
+ },
45
+ }
46
+ </script>
47
+
48
+ <style scoped>
49
+ .chat-list {
50
+ flex: 1;
51
+ min-height: 0;
52
+ overflow: auto;
53
+ border-radius: 10px;
54
+ padding: 8px;
55
+ }
56
+
57
+ .chat-welcome {
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: center;
61
+ margin-bottom: 10%;
62
+
63
+ &.tr-welcome {
64
+ --title-color: var(--tr-text-primary);
65
+ --description-color: var(--tr-text-secondary);
66
+ }
67
+ }
68
+
69
+ :deep() {
70
+ [data-box-type='box'][data-role='user'] {
71
+ --tr-bubble-box-bg: var(--tr-color-primary-light);
72
+ }
73
+
74
+ [data-box-type='box']:not([data-role='user']) {
75
+ --tr-bubble-box-bg: transparent;
76
+ }
77
+
78
+ [data-type='markdown'] p {
79
+ margin: 0;
80
+ }
81
+ }
82
+ </style>