Skip to content

Commit 3609fd8

Browse files
author
linyuan.yang
committed
skill ls tool
1 parent 53ec816 commit 3609fd8

9 files changed

Lines changed: 286 additions & 125 deletions

File tree

packages/app/src/App.vue

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
<script setup lang="ts">
2-
import { ref } from 'vue'
2+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
33
import { ChatView, ServerPicker, WebSocketTransport } from '@sbot/chat-ui'
44
import type { RemoteEntry } from '@sbot/chat-ui'
5+
import ThemeMenu from './ThemeMenu.vue'
56
import '@sbot/chat-ui/themes/variables.css'
6-
import '@sbot/chat-ui/themes/theme-dark.css'
7+
import lightThemeCSS from '@sbot/chat-ui/themes/theme-light.css?inline'
8+
import darkThemeCSS from '@sbot/chat-ui/themes/theme-dark.css?inline'
79
import '@sbot/chat-ui/themes/theme-pwa.css'
810
import '@sbot/chat-ui/themes/sbot-ui-bridge.css'
911
1012
const DEFAULT_PORT = 5500
1113
const REMOTES_KEY = 'sbot-app-remotes'
14+
const THEME_KEY = 'sbot-app-theme'
15+
16+
type ThemeMode = 'system' | 'light' | 'dark'
1217
1318
function loadRemotes(): RemoteEntry[] {
1419
try { return JSON.parse(localStorage.getItem(REMOTES_KEY) || '[]') }
@@ -23,6 +28,34 @@ const phase = ref<'server-pick' | 'chat'>('server-pick')
2328
const transport = ref<WebSocketTransport | null>(null)
2429
const currentBaseUrl = ref('')
2530
31+
const themeMode = ref<ThemeMode>(((): ThemeMode => {
32+
const v = localStorage.getItem(THEME_KEY)
33+
return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'
34+
})())
35+
36+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
37+
const themeStyleEl = document.createElement('style')
38+
themeStyleEl.id = 'sbot-app-theme'
39+
document.head.appendChild(themeStyleEl)
40+
41+
const resolvedTheme = computed<'light' | 'dark'>(() =>
42+
themeMode.value === 'system' ? (mq.matches ? 'dark' : 'light') : themeMode.value,
43+
)
44+
45+
function applyTheme(t: 'light' | 'dark') {
46+
themeStyleEl.textContent = t === 'dark' ? darkThemeCSS : lightThemeCSS
47+
document.documentElement.dataset.theme = t
48+
}
49+
50+
watch(resolvedTheme, applyTheme, { immediate: true })
51+
watch(themeMode, (m) => localStorage.setItem(THEME_KEY, m))
52+
53+
function onSystemChange() {
54+
if (themeMode.value === 'system') applyTheme(resolvedTheme.value)
55+
}
56+
onMounted(() => mq.addEventListener('change', onSystemChange))
57+
onUnmounted(() => mq.removeEventListener('change', onSystemChange))
58+
2659
function selectServer(baseUrl: string) {
2760
transport.value = new WebSocketTransport(baseUrl)
2861
currentBaseUrl.value = baseUrl
@@ -70,6 +103,10 @@ function removeRemote(index: number) {
70103

71104
<template>
72105
<div class="desktop-app">
106+
<div v-if="phase === 'server-pick'" class="theme-menu-floating">
107+
<ThemeMenu :mode="themeMode" @update="(m) => themeMode = m" />
108+
</div>
109+
73110
<template v-if="phase === 'server-pick'">
74111
<ServerPicker
75112
:remotes="remotes"
@@ -83,6 +120,7 @@ function removeRemote(index: number) {
83120
<template v-else-if="transport">
84121
<div class="desktop-server-bar">
85122
<span class="desktop-server-url">{{ currentBaseUrl }}</span>
123+
<ThemeMenu :mode="themeMode" @update="(m) => themeMode = m" />
86124
<button class="desktop-server-switch" @click="switchServer">切换服务器</button>
87125
</div>
88126
<ChatView :transport="transport" :show-attachments="true" />
@@ -95,8 +133,8 @@ function removeRemote(index: number) {
95133
body {
96134
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
97135
font-size: 14px;
98-
color: var(--chatui-fg, #e8e6e3);
99-
background: var(--chatui-bg, #1a1a2e);
136+
color: var(--chatui-fg);
137+
background: var(--chatui-bg);
100138
overflow: hidden;
101139
}
102140
</style>
@@ -111,32 +149,38 @@ body {
111149
.desktop-server-bar {
112150
display: flex;
113151
align-items: center;
114-
justify-content: space-between;
152+
gap: 8px;
115153
padding: 6px 12px;
116-
background: var(--chatui-bg-surface, #252540);
117-
border-bottom: 1px solid var(--chatui-border, #3a3a5c);
154+
background: var(--chatui-bg-surface);
155+
border-bottom: 1px solid var(--chatui-border);
118156
flex-shrink: 0;
119157
font-size: 12px;
120158
}
121159
.desktop-server-url {
122-
color: var(--chatui-fg-secondary, #888);
160+
flex: 1;
161+
color: var(--chatui-fg-secondary);
123162
font-family: monospace;
124163
overflow: hidden;
125164
text-overflow: ellipsis;
126165
white-space: nowrap;
127166
}
128167
.desktop-server-switch {
129-
margin-left: 8px;
130168
padding: 2px 10px;
131-
border: 1px solid var(--chatui-border, #3a3a5c);
169+
border: 1px solid var(--chatui-border);
132170
border-radius: 4px;
133171
background: transparent;
134172
cursor: pointer;
135173
font-size: 12px;
136-
color: var(--chatui-fg, #e8e6e3);
174+
color: var(--chatui-fg);
137175
flex-shrink: 0;
138176
}
139177
.desktop-server-switch:hover {
140-
background: var(--chatui-bg-hover, #2f2f4a);
178+
background: var(--chatui-bg-hover);
179+
}
180+
.theme-menu-floating {
181+
position: fixed;
182+
top: 8px;
183+
right: 12px;
184+
z-index: 10;
141185
}
142186
</style>

packages/app/src/ThemeMenu.vue

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<script setup lang="ts">
2+
import { onMounted, onUnmounted, ref } from 'vue'
3+
4+
type ThemeMode = 'light' | 'dark' | 'system'
5+
6+
defineProps<{ mode: ThemeMode }>()
7+
const emit = defineEmits<{ update: [ThemeMode] }>()
8+
9+
const open = ref(false)
10+
const root = ref<HTMLElement | null>(null)
11+
12+
const options: { value: ThemeMode; label: string }[] = [
13+
{ value: 'light', label: '浅色' },
14+
{ value: 'dark', label: '深色' },
15+
{ value: 'system', label: '跟随系统' },
16+
]
17+
18+
const labelOf = (m: ThemeMode) => options.find(o => o.value === m)!.label
19+
20+
function pick(m: ThemeMode) {
21+
emit('update', m)
22+
open.value = false
23+
}
24+
25+
function onDocClick(e: MouseEvent) {
26+
if (!open.value) return
27+
if (root.value && !root.value.contains(e.target as Node)) open.value = false
28+
}
29+
onMounted(() => document.addEventListener('click', onDocClick))
30+
onUnmounted(() => document.removeEventListener('click', onDocClick))
31+
</script>
32+
33+
<template>
34+
<div ref="root" class="theme-menu">
35+
<button
36+
class="theme-trigger"
37+
:title="`主题:${labelOf(mode)}`"
38+
@click="open = !open"
39+
>
40+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
41+
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
42+
<line x1="8" y1="21" x2="16" y2="21" />
43+
<line x1="12" y1="17" x2="12" y2="21" />
44+
</svg>
45+
<span>{{ labelOf(mode) }}</span>
46+
</button>
47+
<div v-if="open" class="theme-dropdown">
48+
<div
49+
v-for="o in options"
50+
:key="o.value"
51+
class="theme-option"
52+
:class="{ active: mode === o.value }"
53+
@click.stop="pick(o.value)"
54+
>{{ o.label }}</div>
55+
</div>
56+
</div>
57+
</template>
58+
59+
<style scoped>
60+
.theme-menu { position: relative; display: inline-block; }
61+
.theme-trigger {
62+
display: inline-flex;
63+
align-items: center;
64+
gap: 4px;
65+
padding: 2px 8px;
66+
border: 1px solid var(--chatui-border);
67+
border-radius: 4px;
68+
background: transparent;
69+
cursor: pointer;
70+
font-size: 12px;
71+
color: var(--chatui-fg);
72+
}
73+
.theme-trigger:hover { background: var(--chatui-bg-hover); }
74+
.theme-dropdown {
75+
position: absolute;
76+
top: calc(100% + 4px);
77+
right: 0;
78+
min-width: 110px;
79+
background: var(--chatui-bg-surface);
80+
border: 1px solid var(--chatui-border);
81+
border-radius: 4px;
82+
padding: 4px 0;
83+
z-index: 20;
84+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
85+
}
86+
.theme-option {
87+
padding: 6px 12px;
88+
font-size: 12px;
89+
color: var(--chatui-fg);
90+
cursor: pointer;
91+
white-space: nowrap;
92+
}
93+
.theme-option:hover { background: var(--chatui-bg-hover); }
94+
.theme-option.active { font-weight: 600; color: var(--chatui-accent); }
95+
</style>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
List files inside a skill directory. 📁 marks sub-directories, 📄 marks files; sub-directories are listed first. Defaults to the skill root and depth=1 (current level only). Pass subPath to drill into a sub-directory, or depth (1-5) to expand more levels at once. Common build/cache/runtime directories (node_modules, .git, __pycache__, .runtime, etc.) are filtered out.
1+
List files inside a skill directory as a flat list of relative paths. Directories end with `/`; sub-directories are listed first. Defaults to the skill root with maxDepth=3 and limit=200. Pass subPath to drill into a sub-directory, maxDepth to control recursion (1 = direct children only), or limit to cap entries. Truncated output is marked in the trailing summary. Common build/cache/runtime directories (node_modules, .git, __pycache__, .runtime, etc.) are filtered out.
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
Lists files and directories under a given path as a tree (up to 100 files). The path must be absolute. Automatically ignores common build/dependency directories (node_modules, .git, dist, build, etc.). Pass additional names via ignore to exclude more directories or files.
1+
Lists files and directories under a given path as a flat list of paths relative to dirPath, with directories suffixed by `/`. The path must be absolute. Automatically ignores common build/dependency directories (node_modules, .git, dist, build, etc.). Pass additional names via ignore to exclude more.
2+
Use maxDepth to control recursion (1 = direct children only; default 3). Truncates at 200 entries — increase specificity (deeper subdir or smaller maxDepth) when truncated.
23
Prefer grep to search by file content, or glob to find files by name pattern when you know what you're looking for.

packages/sbot/src/Tools/FileSystem/operations/ls.ts

Lines changed: 9 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,36 @@
1-
import fs from 'fs';
2-
import path from 'path';
31
import { DynamicStructuredTool, type StructuredToolInterface } from '@langchain/core/tools';
42
import { z } from 'zod';
53
import { LoggerService } from '../../../Core/LoggerService';
6-
import { createTextContent, createErrorResult, createSuccessResult, MCPToolResult } from 'scorpio.ai';
4+
import { createTextContent, createErrorResult, createSuccessResult, MCPToolResult, formatWalkTree, DEFAULT_WALK_MAX_DEPTH, DEFAULT_WALK_LIMIT } from 'scorpio.ai';
75
import { checkDir } from '../utils';
86
import { loadPrompt } from '../../../Core/PromptLoader';
97

108
const logger = LoggerService.getLogger('Tools/FileSystem/operations/ls.ts');
119

12-
const IGNORE_PATTERNS = [
10+
const IGNORE_PATTERNS = new Set([
1311
'node_modules', '__pycache__', '.git', 'dist', 'build',
1412
'target', 'vendor', 'bin', 'obj', '.idea', '.vscode',
1513
'.zig-cache', 'zig-out', '.coverage', 'coverage',
1614
'tmp', 'temp', '.cache', 'cache', 'logs',
1715
'.venv', 'venv', 'env',
18-
];
16+
]);
1917

20-
const LIMIT = 100;
21-
22-
interface TreeNode {
23-
name: string;
24-
isDir: boolean;
25-
children: TreeNode[];
26-
}
27-
28-
/** 以树形文本列出目录结构,自动忽略常见构建/依赖目录 */
18+
/** 列出目录内容(扁平相对路径;目录加 `/` 后缀;自动忽略常见构建/依赖目录) */
2919
export function createLsTool(): StructuredToolInterface {
3020
return new DynamicStructuredTool({
3121
name: 'ls',
3222
description: loadPrompt('tools/fs/ls.txt'),
3323
schema: z.object({
3424
dirPath: z.string().describe('Absolute path of the directory to list'),
25+
maxDepth: z.number().int().positive().optional().default(DEFAULT_WALK_MAX_DEPTH).describe(`Max recursion depth (1 = direct children only). Default ${DEFAULT_WALK_MAX_DEPTH}`),
26+
limit: z.number().int().positive().optional().default(DEFAULT_WALK_LIMIT).describe(`Stop after this many entries (files + directories). Default ${DEFAULT_WALK_LIMIT}`),
3527
ignore: z.array(z.string()).optional().describe('Additional directory/file names to ignore'),
3628
}) as any,
37-
func: async ({ dirPath, ignore = [] }: any): Promise<MCPToolResult> => {
29+
func: async ({ dirPath, maxDepth = DEFAULT_WALK_MAX_DEPTH, limit = DEFAULT_WALK_LIMIT, ignore = [] }: any): Promise<MCPToolResult> => {
3830
try {
3931
const abs = checkDir(dirPath);
40-
const extraIgnore: string[] = ignore ?? [];
41-
const shouldIgnore = (name: string) =>
42-
IGNORE_PATTERNS.includes(name) || extraIgnore.includes(name);
43-
44-
let fileCount = 0;
45-
let dirCount = 0;
46-
47-
function walk(dir: string): TreeNode[] {
48-
let entries: fs.Dirent[];
49-
try {
50-
entries = fs.readdirSync(dir, { withFileTypes: true });
51-
} catch { return []; }
52-
53-
// 排序:目录在前,文件在后,各自按名称排序
54-
entries.sort((a, b) => {
55-
const aDir = a.isDirectory() ? 0 : 1;
56-
const bDir = b.isDirectory() ? 0 : 1;
57-
if (aDir !== bDir) return aDir - bDir;
58-
return a.name.localeCompare(b.name);
59-
});
60-
61-
const nodes: TreeNode[] = [];
62-
for (const entry of entries) {
63-
if (fileCount >= LIMIT) break;
64-
if (shouldIgnore(entry.name)) continue;
65-
66-
if (entry.isDirectory()) {
67-
dirCount++;
68-
const children = fileCount < LIMIT
69-
? walk(path.join(dir, entry.name))
70-
: [];
71-
nodes.push({ name: entry.name, isDir: true, children });
72-
} else {
73-
fileCount++;
74-
nodes.push({ name: entry.name, isDir: false, children: [] });
75-
}
76-
}
77-
return nodes;
78-
}
79-
80-
const tree = walk(abs);
81-
82-
// 渲染树形结构
83-
const lines: string[] = [`${abs}/`];
84-
85-
function render(nodes: TreeNode[], prefix: string) {
86-
for (let i = 0; i < nodes.length; i++) {
87-
const node = nodes[i];
88-
const isLast = i === nodes.length - 1;
89-
const connector = isLast ? '└── ' : '├── ';
90-
const label = node.isDir ? `${node.name}/` : node.name;
91-
lines.push(`${prefix}${connector}${label}`);
92-
93-
if (node.isDir && node.children.length > 0) {
94-
const childPrefix = prefix + (isLast ? ' ' : '│ ');
95-
render(node.children, childPrefix);
96-
}
97-
}
98-
}
99-
100-
render(tree, '');
101-
102-
// 汇总信息
103-
const parts: string[] = [];
104-
parts.push(`${dirCount} director${dirCount === 1 ? 'y' : 'ies'}`);
105-
parts.push(`${fileCount} file${fileCount === 1 ? '' : 's'}`);
106-
if (fileCount >= LIMIT) parts.push('truncated');
107-
lines.push('', parts.join(', '));
108-
109-
return createSuccessResult(createTextContent(lines.join('\n')));
32+
const ignoreSet = new Set<string>([...IGNORE_PATTERNS, ...(ignore ?? [])]);
33+
return createSuccessResult(createTextContent(formatWalkTree(abs, { maxDepth, limit, ignore: ignoreSet })));
11034
} catch (e: any) {
11135
logger.error(`ls ${dirPath}: ${e.message}`);
11236
return createErrorResult(e.message);

0 commit comments

Comments
 (0)