fix: Tauri reload, multi-conv blocking, skill install, UI polish
Deploy to Production / deploy (push) Waiting to run Details

1) Tauri reload login: main.ts beginStartup before mount + router guard await waitForStartup

2) Multi-conversation blocking: per-conversation Map/Set tracking

3) Skill install: npx --yes + SKILL.md support + path traversal validation

4) Markdown table rendering + 80ms streaming debounce

5) Agent execution UI: structured IStreamingStep

6) auth.py _ensure_db idempotent

Code review fixes: renderTimer cleanup, counter accumulation, memory leak, WS reconnect stale steps
This commit is contained in:
chiguyong 2026-06-23 11:03:46 +08:00
parent 7e0ef6d1ac
commit bc424574c7
12 changed files with 842 additions and 160 deletions

33
.trae/rules/ponytail.md Normal file
View File

@ -0,0 +1,33 @@
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does the standard library already do this? Use it.
3. Does a native platform feature cover it? Use it.
4. Does an already-installed dependency solve it? Use it.
5. Can this be one line? Make it one line.
6. Only then: write the minimum code that works.
## Rules
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
## Never lazy about
- Input validation at trust boundaries.
- Error handling that prevents data loss.
- Security.
- Accessibility.
- The calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off).
- Anything explicitly requested.
Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.

View File

@ -11,15 +11,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { useRouter } from 'vue-router'
import { useThemeStore } from './stores/theme'
import { useAuthStore } from './stores/auth'
import { isTauri, startBackend, checkBackendHealth } from './api/tauri'
import { isTauri } from './api/tauri'
import {
initApiBaseURL,
setTokenProvider,
setRefreshProvider,
setUnauthorizedHandler,
@ -32,8 +31,10 @@ const authStore = useAuthStore()
const router = useRouter()
const loading = ref(isTauri())
const loadingStatus = ref('正在初始化...')
const loadError = ref('')
// Mirror the auth store's bootstrap progress so the SplashScreen shows
// "..." / "..." etc.
const loadingStatus = computed(() => authStore.bootstrapStatus || '正在初始化...')
onMounted(async () => {
// Wire the auth store into the API client so every request gets a JWT
@ -49,51 +50,43 @@ onMounted(async () => {
router.replace({ name: 'login' })
})
// Cold-start auth: rehydrate from the persisted refresh token.
// This populates user + access token WITHOUT forcing a login.
// The router guard then decides whether to keep the user where
// they are or redirect to /login.
await authStore.startupCheck()
// The cold-start (Tauri backend bootstrap + whoami) was kicked off in
// ``main.ts`` before mount. ``router.beforeEach`` already awaited it,
// so by the time we get here the startup state is final. We still
// await ``waitForStartup()`` for the SplashScreen to transition
// correctly based on the result.
if (isTauri()) {
try {
await bootstrapBackend()
} catch (err: unknown) {
loadError.value = err instanceof Error ? err.message : String(err)
const result = await authStore.waitForStartup()
if (result === 'valid') {
loading.value = false
} else if (result === 'error') {
loadError.value = authStore.error || '后端服务不可用'
}
// 'invalid' loading stays true briefly but the router guard has
// already redirected to /login; flip loading off so /login renders.
if (result === 'invalid') {
loading.value = false
}
} else {
// Non-Tauri (browser): the router guard awaited the probe too;
// no splash needed.
loading.value = false
}
})
/** Run the Tauri-side backend bootstrap (start + init URL + health check).
*
* Exposed to the template so the user can click "重试" when the initial
* health check fails (e.g. the Python server was not up at the moment
* the window opened). */
async function bootstrapBackend(): Promise<void> {
loadingStatus.value = '正在启动后端...'
await startBackend()
loadingStatus.value = '正在配置连接...'
await initApiBaseURL()
loadingStatus.value = '正在检查服务...'
const healthy = await checkBackendHealth()
if (healthy) {
loadError.value = ''
loading.value = false
} else {
loadError.value = '后端服务健康检查失败,请确认 agentkit serve 已在 8000 端口运行后点击重试。'
}
}
/** User-triggered retry from the error screen. */
async function retryBootstrap(): Promise<void> {
loadError.value = ''
loading.value = true
try {
await bootstrapBackend()
} catch (err: unknown) {
loadError.value = err instanceof Error ? err.message : String(err)
const result = await authStore.retryStartup()
if (result === 'valid') {
loading.value = false
} else if (result === 'error') {
loadError.value = authStore.error || '后端服务不可用'
} else if (result === 'invalid') {
// Refresh token turned out invalid send the user to /login.
loading.value = false
router.replace({ name: 'login' })
}
}
</script>
@ -114,7 +107,7 @@ html, body, #app {
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
'Noto Sans Emoji';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--text-primary, #1a1a1a);

View File

@ -449,7 +449,6 @@ function removePill(idx: number): void {
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0;
border-top: 1px solid var(--border-color);
}
.chat-input__textarea {

View File

@ -35,7 +35,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue'
import MarkdownIt from 'markdown-it'
import DOMPurify from 'dompurify'
import { Tag as ATag, Spin as ASpin, message as antMessage } from 'ant-design-vue'
@ -121,9 +121,57 @@ const showRouting = computed(() => {
return props.message.role === 'assistant' && props.message.matched_skill
})
const renderedContent = computed(() => {
if (!props.message.content) return ''
return sanitize(md.render(props.message.content))
/**
* When the assistant is still streaming, defer re-parsing the markdown by a
* short window so we don't re-render on every token delta (which causes
* visible flicker on tables and code fences). When streaming ends, render
* immediately.
*/
const isStreaming = computed(
() => props.message.role === 'assistant' && props.message.status === 'pending',
)
const renderedContent = ref<string>('')
let renderTimer: ReturnType<typeof setTimeout> | null = null
function doRender(): void {
renderTimer = null
if (!props.message.content) {
renderedContent.value = ''
return
}
renderedContent.value = sanitize(md.render(props.message.content))
}
watch(
() => props.message.content,
() => {
if (renderTimer !== null) {
clearTimeout(renderTimer)
renderTimer = null
}
if (isStreaming.value) {
renderTimer = setTimeout(doRender, 80)
} else {
doRender()
}
},
{ immediate: true },
)
watch(isStreaming, (now) => {
if (!now && renderTimer !== null) {
clearTimeout(renderTimer)
renderTimer = null
doRender()
}
})
onBeforeUnmount(() => {
if (renderTimer !== null) {
clearTimeout(renderTimer)
renderTimer = null
}
})
const toolCalls = computed(() => props.message.tool_calls ?? [])
@ -298,6 +346,41 @@ watch(renderedContent, () => {
margin-bottom: var(--space-3);
}
.assistant-text__markdown :deep(table) {
width: 100%;
border-collapse: collapse;
margin: var(--space-3) 0;
font-size: var(--font-sm);
background: var(--bg-secondary);
border-radius: var(--radius-md);
overflow: hidden;
}
.assistant-text__markdown :deep(thead) {
background: rgba(0, 0, 0, 0.18);
}
.assistant-text__markdown :deep(th),
.assistant-text__markdown :deep(td) {
padding: var(--space-2) var(--space-3);
text-align: left;
border-bottom: 1px solid var(--border-color);
vertical-align: top;
}
.assistant-text__markdown :deep(th) {
font-weight: var(--font-weight-semibold, 600);
color: var(--text-primary);
}
.assistant-text__markdown :deep(tbody tr:last-child td) {
border-bottom: none;
}
.assistant-text__markdown :deep(tbody tr:hover) {
background: rgba(255, 255, 255, 0.03);
}
.assistant-text__markdown :deep(h1),
.assistant-text__markdown :deep(h2),
.assistant-text__markdown :deep(h3) {

View File

@ -4,10 +4,19 @@ import 'ant-design-vue/dist/reset.css'
import './styles'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
const app = createApp(App)
app.use(createPinia())
const pinia = createPinia()
app.use(pinia)
app.use(router)
// Kick off the cold-start auth probe BEFORE mount so that
// ``router.beforeEach`` can ``await authStore.waitForStartup()`` instead
// of seeing ``startupState === 'pending'`` and redirecting to /login.
// (Tauri reload bug: the guard ran before App.vue::onMounted had a
// chance to call startupCheck, so every reload bounced to /login.)
const authStore = useAuthStore(pinia)
authStore.beginStartup()
app.mount('#app')

View File

@ -214,21 +214,26 @@ const router = createRouter({
* users are redirected to /login with a ``redirect`` query param
* preserving the original target.
*
* Note: the cold-start auth rehydration happens in App.vue's
* ``onMounted`` (calls ``authStore.startupCheck()``) BEFORE this guard
* runs, so by the time the guard executes the store's
* ``isAuthenticated`` is in its true post-startup state.
* CRITICAL: ``router.beforeEach`` runs BEFORE ``App.vue::onMounted``.
* On a Tauri reload the access token is gone (memory-only) and
* ``startupState`` is still ``'pending'`` because ``startupCheck()``
* hasn't run yet. Without waiting, the guard would see
* ``isAuthenticated === false`` and bounce every reload to /login.
*
* The 3-state startup distinguishes:
* We therefore ``await authStore.waitForStartup()`` when the state is
* still pending. The cold-start is kicked off in ``main.ts`` before
* ``app.mount()`` so the promise is already in flight by the time the
* first navigation resolves.
*
* Startup states after the await:
* - 'valid' authenticated, allow
* - 'invalid' not authenticated, redirect to /login
* - 'error' server unreachable, redirect to /login with a
* "正在重试" hint
* - 'pending' still resolving; allow the navigation (the store
* hasn't blocked it yet) so the user doesn't see a
* flash of /login
* - 'error' backend unreachable / health check failed; allow the
* navigation through so ``App.vue``'s SplashScreen can
* surface the error + retry button (the splash overlays
* ``<router-view>`` so no protected content leaks).
*/
router.beforeEach((to, _from, next) => {
router.beforeEach(async (to, _from, next) => {
const title = to.meta.title as string | undefined
if (title) {
document.title = `${title} - Fischer AgentKit`
@ -241,6 +246,18 @@ router.beforeEach((to, _from, next) => {
}
const authStore = useAuthStore()
// Wait for the cold-start probe (Tauri bootstrap + whoami) to finish
// before making any routing decision.
if (authStore.startupState === 'pending') {
await authStore.waitForStartup()
}
// Backend error — let App.vue's SplashScreen show the retry UI.
if (authStore.startupState === 'error') {
next()
return
}
if (!authStore.isAuthenticated) {
// Preserve the original target so we can redirect after login
next({

View File

@ -25,6 +25,8 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi, type IAuthUser, type ITokenPair, type ISessionInfo } from '@/api/auth'
import { tauriAuthStorage } from '@/api/tauri-auth'
import { isTauri, startBackend, checkBackendHealth } from '@/api/tauri'
import { initApiBaseURL } from '@/api/base'
const USER_KEY = 'agentkit.user'
@ -342,9 +344,79 @@ export const useAuthStore = defineStore('auth', () => {
startupState.value = 'invalid'
}
// --- Cold-start orchestration (fixes Tauri reload → /login bug) ---
//
// The problem: ``router.beforeEach`` runs BEFORE ``App.vue::onMounted``,
// so on a Tauri reload the guard sees ``startupState === 'pending'`` and
// ``accessToken === null`` (memory-only), concludes ``isAuthenticated ===
// false``, and redirects to /login — before ``startupCheck()`` ever runs.
//
// Fix: ``main.ts`` calls ``beginStartup()`` BEFORE ``app.mount()``. The
// router guard then ``await``s ``waitForStartup()`` so it doesn't make
// a routing decision until the cold-start probe is done.
let _startupPromise: Promise<AuthStartupState> | null = null
/** Human-readable progress string for the SplashScreen (Tauri mode). */
const bootstrapStatus = ref('')
/**
* Kick off the cold-start sequence (Tauri backend bootstrap + whoami).
* Called from ``main.ts`` before ``app.mount()``. Idempotent subsequent
* calls are no-ops (the in-flight promise is reused).
*/
function beginStartup(): void {
if (_startupPromise) return
_startupPromise = _doStartup()
}
/**
* The actual cold-start work. In Tauri mode the Python sidecar must be
* up and the API base URL configured BEFORE we can call ``/auth/whoami``.
*/
async function _doStartup(): Promise<AuthStartupState> {
if (isTauri()) {
try {
bootstrapStatus.value = '正在启动后端...'
await startBackend()
bootstrapStatus.value = '正在配置连接...'
await initApiBaseURL()
bootstrapStatus.value = '正在检查服务...'
const healthy = await checkBackendHealth()
if (!healthy) {
startupState.value = 'error'
error.value =
'后端服务健康检查失败,请确认 agentkit serve 已在 8000 端口运行后点击重试。'
return startupState.value
}
} catch (err: unknown) {
startupState.value = 'error'
error.value = err instanceof Error ? err.message : String(err)
return startupState.value
}
}
bootstrapStatus.value = '正在验证登录状态...'
return await startupCheck()
}
/**
* Await the cold-start probe. Used by ``router.beforeEach`` so the guard
* doesn't redirect to /login while the refresh-token validation is still
* in flight.
*/
async function waitForStartup(): Promise<AuthStartupState> {
if (!_startupPromise) {
beginStartup()
}
return _startupPromise!
}
/** Force re-evaluation of the startup state (e.g. after a manual retry). */
async function retryStartup(): Promise<AuthStartupState> {
return await startupCheck()
_startupPromise = null
startupState.value = 'pending'
error.value = null
beginStartup()
return await waitForStartup()
}
// --- Session management (U4/U8 surface) ---
@ -392,6 +464,7 @@ export const useAuthStore = defineStore('auth', () => {
error,
sessionId,
startupState,
bootstrapStatus,
// getters
isAuthenticated,
role,
@ -404,6 +477,8 @@ export const useAuthStore = defineStore('auth', () => {
logoutLocal,
silentRefresh,
startupCheck,
beginStartup,
waitForStartup,
retryStartup,
listSessions,
revokeSession,

View File

@ -14,14 +14,126 @@ function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* Structured streaming progress entries. Token deltas are aggregated into
* a single entry that is updated in place so the UI doesn't spam one
* "步骤 3: turn.token" line per chunk.
*/
export type StreamingStepType =
| 'routing'
| 'thinking'
| 'tool_call'
| 'tool_result'
| 'streaming'
| 'final_answer'
| 'team_event'
| 'board_event'
| 'milestone'
export interface IStreamingStep {
id: string
type: StreamingStepType
label: string
detail?: string
/** Aggregate counter for `streaming` (chars streamed) or `tool_call` (duration ms). */
counter?: number
status: 'running' | 'success' | 'error'
/** Updated monotonically; used as a render hint for the streaming entry. */
ts: number
}
export const useChatStore = defineStore('chat', () => {
// --- State ---
const conversations = ref<IConversation[]>([])
const currentConversationId = ref<string | null>(null)
const isLoading = ref(false)
const isWsConnected = ref(false)
const ws = ref<WebSocket | null>(null)
const streamingSteps = ref<string[]>([])
/**
* Per-conversation in-flight tracking. The UI's `isCurrentLoading` derives
* from the current conversation being in this set, so other tabs remain
* fully usable while one is waiting on the agent.
*/
const pendingConversations = ref<Set<string>>(new Set())
const pendingLastUsedAt = ref<Map<string, number>>(new Map())
const streamingStepsByConv = ref<Map<string, IStreamingStep[]>>(new Map())
// --- Step helpers (structured, aggregated, per-conversation) ---
function getConvSteps(convId: string): IStreamingStep[] {
let arr = streamingStepsByConv.value.get(convId)
if (!arr) {
arr = []
streamingStepsByConv.value.set(convId, arr)
}
return arr
}
function appendStep(
step: Omit<IStreamingStep, 'id' | 'ts'>,
convId: string = currentConversationId.value ?? '',
): void {
if (!convId) return
getConvSteps(convId).push({ ...step, id: generateId(), ts: Date.now() })
}
function updateLastStep(
predicate: (s: IStreamingStep) => boolean,
patch: Partial<IStreamingStep>,
convId: string = currentConversationId.value ?? '',
): void {
if (!convId) return
const arr = streamingStepsByConv.value.get(convId)
if (!arr) return
const idx = [...arr].reverse().findIndex(predicate)
if (idx === -1) return
const realIdx = arr.length - 1 - idx
arr[realIdx] = { ...arr[realIdx], ...patch, ts: Date.now() }
}
function clearConvSteps(convId: string): void {
// Delete the key entirely rather than setting an empty array —
// otherwise the Map grows unboundedly across conversations.
streamingStepsByConv.value.delete(convId)
}
// --- Per-conversation pending tracking ---
function markConversationPending(convId: string): void {
pendingConversations.value = new Set(pendingConversations.value).add(convId)
pendingLastUsedAt.value = new Map(pendingLastUsedAt.value).set(convId, Date.now())
}
function markConversationDone(convId: string): void {
const next = new Set(pendingConversations.value)
next.delete(convId)
pendingConversations.value = next
const last = new Map(pendingLastUsedAt.value)
last.delete(convId)
pendingLastUsedAt.value = last
}
/**
* Resolve which conversation an incoming WS message belongs to.
*
* The backend protocol currently does NOT tag serverclient messages with
* `conversation_id`, so we route by recency: pick the most recently used
* pending conversation. This is a heuristic it works as long as users
* don't fire two requests in quick succession across conversations. We
* bias toward the *current* view if it's still pending, which is what the
* user is watching right now.
*/
function resolveIncomingConvId(): string {
const cid = currentConversationId.value
if (cid && pendingConversations.value.has(cid)) return cid
// Fall back to the most recently used pending conversation.
let best: string | null = null
let bestTs = 0
pendingLastUsedAt.value.forEach((ts, id) => {
if (pendingConversations.value.has(id) && ts > bestTs) {
best = id
bestTs = ts
}
})
return best ?? cid ?? ''
}
// Board Meeting state (transient, only active during a board discussion)
const boardState = ref<{
@ -43,6 +155,25 @@ export const useChatStore = defineStore('chat', () => {
return currentConversation.value?.messages ?? []
})
/**
* `true` only when the *currently viewed* conversation is waiting on the
* agent. Other in-flight conversations do NOT block the input box.
*/
const isCurrentLoading = computed<boolean>(() => {
const cid = currentConversationId.value
return !!cid && pendingConversations.value.has(cid)
})
/**
* Streaming progress for the currently viewed conversation only. Switching
* tabs does not clear another tab's progress.
*/
const currentStreamingSteps = computed<IStreamingStep[]>(() => {
const cid = currentConversationId.value
if (!cid) return []
return streamingStepsByConv.value.get(cid) ?? []
})
// --- Actions ---
/** Load all conversations from the server */
@ -66,7 +197,8 @@ export const useChatStore = defineStore('chat', () => {
/** Select a conversation by ID and load its messages */
async function selectConversation(id: string, force = false): Promise<void> {
currentConversationId.value = id
streamingSteps.value = []
// streamingSteps are scoped per conversation, so switching tabs does NOT
// clear another conversation's in-flight progress.
// Load full conversation with messages if not already loaded (or when forced)
const conv = conversations.value.find((c) => c.id === id)
@ -121,7 +253,8 @@ export const useChatStore = defineStore('chat', () => {
}
conversations.value.unshift(newConversation)
currentConversationId.value = newConversation.id
streamingSteps.value = []
// New conversation starts with empty steps.
clearConvSteps(newConversation.id)
}
/** Send a message using REST API (fallback) */
@ -151,7 +284,7 @@ export const useChatStore = defineStore('chat', () => {
}
appendMessage(conversationId, assistantMessage)
isLoading.value = true
markConversationPending(conversationId)
try {
const request: IChatRequest = {
@ -182,7 +315,7 @@ export const useChatStore = defineStore('chat', () => {
status: 'completed',
})
} finally {
isLoading.value = false
markConversationDone(conversationId)
}
}
@ -220,8 +353,8 @@ export const useChatStore = defineStore('chat', () => {
}
appendMessage(conversationId, assistantMessage)
isLoading.value = true
streamingSteps.value = []
markConversationPending(conversationId)
clearConvSteps(conversationId)
const wsMessage: WsClientMessage = {
type: 'chat',
@ -243,7 +376,7 @@ export const useChatStore = defineStore('chat', () => {
(m) => m.id !== userMessage.id && m.id !== assistantMessage.id,
)
}
isLoading.value = false
markConversationDone(conversationId)
await sendMessage(message, sources)
return
}
@ -295,10 +428,14 @@ export const useChatStore = defineStore('chat', () => {
socket.onclose = () => {
isWsConnected.value = false
// P2 #21 fix: reset isLoading to prevent stuck loading state during
// disconnect. _recoverTaskAfterReconnect will re-set it if an active
// task is found after reconnection.
isLoading.value = false
// P2 #21 fix: clear per-conversation pending state to prevent stuck
// loading state during disconnect. _recoverTaskAfterReconnect will
// re-mark conversations pending if an active task is found.
pendingConversations.value = new Set()
pendingLastUsedAt.value = new Map()
// Clear stale streaming steps — _recoverTaskAfterReconnect will
// rebuild them from replayed events if an active task is found.
streamingStepsByConv.value = new Map()
console.log('WebSocket disconnected')
if (_heartbeatTimer) {
clearInterval(_heartbeatTimer)
@ -352,10 +489,13 @@ export const useChatStore = defineStore('chat', () => {
* needs to send the cancel message and guard against send failures.
*/
function stopGeneration(): void {
const cid = currentConversationId.value
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
// No open socket — just reset the loading flag locally.
isLoading.value = false
streamingSteps.value = []
// No open socket — just clear the current conversation's pending state.
if (cid) {
markConversationDone(cid)
clearConvSteps(cid)
}
return
}
try {
@ -363,8 +503,10 @@ export const useChatStore = defineStore('chat', () => {
ws.value.send(JSON.stringify(cancelMsg))
} catch (error) {
console.error('Failed to send cancel message:', error)
isLoading.value = false
streamingSteps.value = []
if (cid) {
markConversationDone(cid)
clearConvSteps(cid)
}
}
}
@ -402,8 +544,10 @@ export const useChatStore = defineStore('chat', () => {
})
}
}
// Problem 5: only set isLoading if we can actually send the resume
isLoading.value = true
// Problem 5: only mark conversation pending if we can actually send
// the resume
const cid = currentConversationId.value
if (cid) markConversationPending(cid)
try {
ws.value.send(
JSON.stringify({
@ -414,7 +558,7 @@ export const useChatStore = defineStore('chat', () => {
)
} catch (error) {
console.error('Failed to send resume message:', error)
isLoading.value = false
if (cid) markConversationDone(cid)
}
} else {
// No active task — force reload conversation messages (may include
@ -458,7 +602,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'routing': {
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
@ -472,12 +616,17 @@ export const useChatStore = defineStore('chat', () => {
routing_method: data.method,
})
}
streamingSteps.value.push(`路由至: ${data.skill} (置信度: ${(data.confidence * 100).toFixed(1)}%)`)
appendStep({
type: 'routing',
label: '智能路由',
detail: `${data.skill} · 置信度 ${(data.confidence * 100).toFixed(1)}%`,
status: 'success',
}, conversationId)
break
}
case 'step': {
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
@ -486,14 +635,96 @@ export const useChatStore = defineStore('chat', () => {
.find((m) => m.role === 'assistant')
const stepInfo = data.data
const innerData = stepInfo.data as Record<string, unknown>
const desc = stepInfo.event_type === 'final_answer'
? '生成最终回答'
: stepInfo.event_type === 'tool_call'
? `调用工具: ${(innerData.tool_name || innerData.name || '#') as string}`
: stepInfo.event_type === 'thinking'
? '思考中...'
: `步骤 ${stepInfo.step || ''}: ${stepInfo.event_type || ''}`
streamingSteps.value.push(desc)
const eventType = stepInfo.event_type as string | undefined
// Aggregate token deltas into a single "streaming" step. We only push
// a new step on transitions between non-token types, otherwise the
// list explodes with one entry per token.
if (eventType === 'token' || eventType === 'turn.token') {
const chunk = (innerData.delta ?? innerData.output ?? innerData.content ?? '') as string
const deltaLen = chunk?.length ?? 0
const convSteps = streamingStepsByConv.value.get(conversationId) ?? []
const hasStreaming = convSteps.some(
(s) => s.type === 'streaming' && s.status === 'running',
)
if (!hasStreaming) {
appendStep({
type: 'streaming',
label: '正在生成回答',
status: 'running',
counter: 0,
}, conversationId)
}
// Accumulate chars into the running streaming step (counter is
// cumulative, not per-chunk). We inline the lookup instead of
// using ``updateLastStep`` because the patch needs the previous
// value — and a backward for-loop avoids the [...arr].reverse()
// allocation on every token (hot path).
const arr = streamingStepsByConv.value.get(conversationId)
if (arr) {
for (let i = arr.length - 1; i >= 0; i--) {
const s = arr[i]
if (s.type === 'streaming' && s.status === 'running') {
arr[i] = {
...s,
counter: (s.counter ?? 0) + (deltaLen > 0 ? deltaLen : 1),
ts: Date.now(),
}
break
}
}
}
} else if (eventType === 'final_answer') {
// Mark the streaming step as completed (if any) then add final.
updateLastStep(
(s) => s.type === 'streaming' && s.status === 'running',
{ status: 'success' },
conversationId,
)
appendStep({
type: 'final_answer',
label: '生成最终回答',
status: 'running',
}, conversationId)
} else if (eventType === 'tool_call') {
const toolName = (innerData.tool_name || innerData.name || '#') as string
// Mark the streaming step done if present.
updateLastStep(
(s) => s.type === 'streaming' && s.status === 'running',
{ status: 'success' },
conversationId,
)
appendStep({
type: 'tool_call',
label: '调用工具',
detail: toolName,
status: 'running',
}, conversationId)
} else if (eventType === 'tool_result') {
const toolName = (innerData.tool_name || innerData.name) as string | undefined
const ok = !innerData.error
updateLastStep(
(s) => s.type === 'tool_call' && s.status === 'running',
{ status: ok ? 'success' : 'error', detail: toolName },
conversationId,
)
} else if (eventType === 'thinking') {
appendStep({
type: 'thinking',
label: '深度思考',
status: 'success',
}, conversationId)
} else if (eventType === 'turn.started' || eventType === 'turn.created') {
// Phase marker — silence; user already sees the spinner.
} else if (eventType === 'turn.thinking' || eventType === 'turn.result') {
// Already handled by thinking/final_answer; ignore.
} else {
appendStep({
type: 'milestone',
label: `${eventType || 'progress'}`,
detail: stepInfo.step ? `step ${stepInfo.step}` : undefined,
status: 'running',
}, conversationId)
}
// Track tool calls for ToolCallCard rendering
if (lastAssistantMsg) {
@ -553,7 +784,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'result': {
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
@ -577,13 +808,16 @@ export const useChatStore = defineStore('chat', () => {
? `${firstUserMsg.content.substring(0, 20)}...`
: firstUserMsg.content
}
isLoading.value = false
streamingSteps.value = []
markConversationDone(conversationId)
// Clear steps for this conversation so they don't accumulate
// across multiple interactions. The UI has already transitioned
// to showing the final assistant message.
clearConvSteps(conversationId)
break
}
case 'error': {
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
@ -609,22 +843,29 @@ export const useChatStore = defineStore('chat', () => {
}
appendMessage(conversationId, errorMsg)
}
isLoading.value = false
streamingSteps.value = []
markConversationDone(conversationId)
clearConvSteps(conversationId)
break
}
case 'team_formed': {
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const teamStore = _getTeamStore()
if (teamStore) {
teamStore.setTeamState(data.data)
}
streamingSteps.value.push(`专家团队已组建: ${data.data.experts.map((e) => e.name).join(', ')}`)
appendStep({
type: 'team_event',
label: '组建专家团',
detail: data.data.experts.map((e) => e.name).join('、'),
status: 'success',
}, conversationId)
break
}
case 'expert_step': {
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
@ -650,12 +891,17 @@ export const useChatStore = defineStore('chat', () => {
}
appendMessage(conversationId, expertMsg)
}
streamingSteps.value.push(`${data.data.expert_name}: 步骤 ${data.data.step}`)
appendStep({
type: 'team_event',
label: data.data.expert_name || '专家',
detail: data.data.step ? `步骤 ${data.data.step}` : undefined,
status: 'running',
}, conversationId)
break
}
case 'expert_result': {
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
@ -679,7 +925,7 @@ export const useChatStore = defineStore('chat', () => {
if (teamStore) {
teamStore.updatePhases(data.data.plan_phases)
}
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
@ -706,7 +952,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'team_synthesis': {
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
@ -727,7 +973,14 @@ export const useChatStore = defineStore('chat', () => {
if (teamStore) {
teamStore.clearTeam()
}
streamingSteps.value.push('专家团队已解散')
const cid = resolveIncomingConvId()
if (cid) {
appendStep({
type: 'team_event',
label: '专家团队已解散',
status: 'success',
}, cid)
}
break
}
@ -735,7 +988,15 @@ export const useChatStore = defineStore('chat', () => {
const teamStore = _getTeamStore()
if (teamStore?.teamState) {
teamStore.updatePhaseStatus(data.data.phase_id, 'in_progress')
streamingSteps.value.push(`阶段开始: ${data.data.phase_name} (${data.data.assigned_expert})`)
const cid = resolveIncomingConvId()
if (cid) {
appendStep({
type: 'team_event',
label: '阶段开始',
detail: `${data.data.phase_name} · ${data.data.assigned_expert}`,
status: 'running',
}, cid)
}
}
break
}
@ -744,7 +1005,15 @@ export const useChatStore = defineStore('chat', () => {
const teamStore = _getTeamStore()
if (teamStore?.teamState) {
teamStore.updatePhaseStatus(data.data.phase_id, 'completed', data.data.result_summary)
streamingSteps.value.push(`阶段完成: ${data.data.phase_name}`)
const cid = resolveIncomingConvId()
if (cid) {
appendStep({
type: 'team_event',
label: '阶段完成',
detail: data.data.phase_name,
status: 'success',
}, cid)
}
}
break
}
@ -753,7 +1022,15 @@ export const useChatStore = defineStore('chat', () => {
const teamStore = _getTeamStore()
if (teamStore?.teamState) {
teamStore.updatePhaseStatus(data.data.phase_id, 'failed', data.data.error)
streamingSteps.value.push(`阶段失败: ${data.data.phase_name} - ${data.data.error}`)
const cid = resolveIncomingConvId()
if (cid) {
appendStep({
type: 'team_event',
label: '阶段失败',
detail: `${data.data.phase_name} · ${data.data.error || ''}`,
status: 'error',
}, cid)
}
}
break
}
@ -776,12 +1053,15 @@ export const useChatStore = defineStore('chat', () => {
current_round: 0,
status: 'discussing',
}
streamingSteps.value.push(
`私董会已开启: 主题「${boardData.topic}」, ${boardData.experts.length} 位专家, 最多 ${boardData.max_rounds}`
)
// Push a structured banner message so the renderer can show BoardBannerCard
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (conversationId) {
appendStep({
type: 'board_event',
label: '私董会开启',
detail: `主题「${boardData.topic}」 · ${boardData.experts.length} 位专家 · 最多 ${boardData.max_rounds}`,
status: 'success',
}, conversationId)
const startMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
@ -803,7 +1083,7 @@ export const useChatStore = defineStore('chat', () => {
if (boardState.value && speechData.round > boardState.value.current_round) {
boardState.value.current_round = speechData.round
}
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const speechMsg: IChatMessage = {
id: generateId(),
@ -819,15 +1099,18 @@ export const useChatStore = defineStore('chat', () => {
board_role: speechData.role,
}
appendMessage(conversationId, speechMsg)
streamingSteps.value.push(
`${speechData.expert_avatar} ${speechData.expert_name} (第${speechData.round}${speechData.role === 'moderator' ? '·主持' : ''})`
)
appendStep({
type: 'board_event',
label: `${speechData.expert_avatar || ''} ${speechData.expert_name}`,
detail: `${speechData.round}${speechData.role === 'moderator' ? ' · 主持' : ''}`,
status: 'success',
}, conversationId)
break
}
case 'round_summary': {
const summaryData = data.data
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const summaryMsg: IChatMessage = {
id: generateId(),
@ -841,13 +1124,26 @@ export const useChatStore = defineStore('chat', () => {
board_role: 'summary',
}
appendMessage(conversationId, summaryMsg)
streamingSteps.value.push(`${summaryData.round}轮小结${summaryData.continue ? '(继续讨论)' : '(即将结束)'}`)
appendStep({
type: 'board_event',
label: `${summaryData.round} 轮小结`,
detail: summaryData.continue ? '继续讨论' : '即将结束',
status: 'success',
}, conversationId)
break
}
case 'user_intervention': {
const interventionData = data.data
streamingSteps.value.push(`用户干预: ${interventionData.content.slice(0, 50)}...`)
const cid = resolveIncomingConvId()
if (cid) {
appendStep({
type: 'board_event',
label: '用户干预',
detail: (interventionData.content || '').slice(0, 50),
status: 'success',
}, cid)
}
break
}
@ -857,12 +1153,15 @@ export const useChatStore = defineStore('chat', () => {
if (boardState.value) {
boardState.value.status = 'completed'
}
streamingSteps.value.push(
`私董会结束: ${conclusionData.total_rounds} 轮讨论${conclusionData.error ? ' (异常)' : ''}`
)
// Push a structured conclusion message so the renderer can show BoardConclusionCard
const conversationId = currentConversationId.value
const conversationId = resolveIncomingConvId()
if (conversationId) {
appendStep({
type: 'board_event',
label: '私董会结束',
detail: `${conclusionData.total_rounds}${conclusionData.error ? ' · 异常' : ''}`,
status: conclusionData.error ? 'error' : 'success',
}, conversationId)
const conclusionMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
@ -912,7 +1211,7 @@ export const useChatStore = defineStore('chat', () => {
async function resendLastUserMessage(): Promise<void> {
const conversationId = currentConversationId.value
if (!conversationId) return
if (isLoading.value) return
if (pendingConversations.value.has(conversationId)) return
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) return
const lastUserMsg = [...conv.messages]
@ -928,13 +1227,20 @@ export const useChatStore = defineStore('chat', () => {
// State
conversations,
currentConversationId,
isLoading,
isWsConnected,
streamingSteps,
ws,
pendingConversations,
streamingStepsByConv,
boardState,
// Legacy aliases (derive from current conversation for backward compat).
// New code should use `isCurrentLoading` / `currentStreamingSteps` instead.
isLoading: isCurrentLoading,
streamingSteps: currentStreamingSteps,
// Getters
currentConversation,
currentMessages,
isCurrentLoading,
currentStreamingSteps,
isBoardMode,
// Actions
loadConversations,

View File

@ -41,15 +41,36 @@
:key="msg.id"
:message="msg"
/>
<!-- Streaming steps -->
<!-- Streaming steps (structured) -->
<div v-if="chatStore.streamingSteps.length > 0" class="chat-view__steps">
<div class="chat-view__steps-header">
<a-typography-text type="secondary">
<LoadingOutlined /> 处理中...
<LoadingOutlined /> 执行中
</a-typography-text>
<div v-for="(step, idx) in chatStore.streamingSteps" :key="idx" class="chat-view__step">
<RightOutlined class="chat-view__step-icon" />
<span>{{ step }}</span>
<span class="chat-view__steps-count">{{ chatStore.streamingSteps.length }} </span>
</div>
<ul class="chat-view__steps-list">
<li
v-for="step in chatStore.streamingSteps"
:key="step.id"
class="chat-view__step"
:class="[
`chat-view__step--${step.type}`,
`chat-view__step--${step.status}`,
]"
>
<span class="chat-view__step-icon" :class="`chat-view__step-icon--${step.status}`">
<template v-if="step.status === 'running'"><LoadingOutlined /></template>
<template v-else-if="step.status === 'success'"><CheckOutlined /></template>
<template v-else><CloseOutlined /></template>
</span>
<span class="chat-view__step-label">{{ step.label }}</span>
<span v-if="step.detail" class="chat-view__step-detail">{{ step.detail }}</span>
<span v-if="step.type === 'streaming' && step.status === 'running' && step.counter !== undefined" class="chat-view__step-counter">
{{ step.counter }} 字符
</span>
</li>
</ul>
</div>
</div>
</div>
@ -76,7 +97,8 @@ import {
PlusOutlined,
RobotOutlined,
LoadingOutlined,
RightOutlined,
CheckOutlined,
CloseOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat'
@ -254,12 +276,40 @@ function handleSend(message: string, model?: string): void {
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.chat-view__steps-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.chat-view__steps-count {
font-size: var(--font-xs);
color: var(--text-tertiary);
background: var(--bg-secondary);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
}
.chat-view__steps-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow-y: auto;
}
.chat-view__input-wrap {
padding: var(--space-3) var(--space-4) var(--space-4);
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
}
.chat-view__input-inner {
@ -271,14 +321,67 @@ function handleSend(message: string, model?: string): void {
.chat-view__step {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 2px 0;
gap: var(--space-2);
padding: 4px 0;
font-size: var(--font-sm);
color: var(--text-secondary);
}
.chat-view__step-icon {
font-size: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 12px;
flex-shrink: 0;
}
.chat-view__step-icon--running {
color: var(--color-primary);
animation: chat-view__step-pulse 1.4s ease-in-out infinite;
}
.chat-view__step-icon--success {
color: var(--color-success, #10b981);
}
.chat-view__step-icon--error {
color: var(--color-error, #ef4444);
}
@keyframes chat-view__step-pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 1; }
}
.chat-view__step-label {
font-weight: var(--font-weight-medium, 500);
color: var(--text-primary);
}
.chat-view__step-detail {
color: var(--text-tertiary);
font-size: var(--font-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
}
.chat-view__step-counter {
margin-left: auto;
font-variant-numeric: tabular-nums;
font-size: var(--font-xs);
color: var(--text-tertiary);
background: var(--bg-secondary);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
white-space: nowrap;
}
.chat-view__step--error .chat-view__step-label,
.chat-view__step--error .chat-view__step-detail {
color: var(--color-error, #ef4444);
}
</style>

View File

@ -195,8 +195,14 @@ def _resolve_jwt_secret(request: Request) -> str:
async def _ensure_db(request: Request) -> Path:
"""Resolve the auth DB path and ensure its schema is initialized.
Always calls ``init_auth_db`` (idempotent via ``CREATE TABLE IF NOT
EXISTS``) so partial / pre-migration databases get missing tables
added on the next request (U5 R8). Setting ``PRAGMA busy_timeout``
inside ``init_auth_db`` also protects against concurrent writes.
"""
db_path = _resolve_db_path(request)
if not db_path.exists():
await init_auth_db(db_path)
return db_path

View File

@ -3,12 +3,17 @@
import asyncio
import logging
import os
import re
from typing import Any, Callable, Awaitable
from agentkit.tools.base import Tool
logger = logging.getLogger(__name__)
# Skill names are used to construct filesystem paths — reject anything
# that could escape the skills directory (path traversal).
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")
class SkillInstallTool(Tool):
"""技能安装工具
@ -74,6 +79,16 @@ class SkillInstallTool(Tool):
"is_error": True,
}
# Validate name — it's used to construct filesystem paths in
# _try_register_skill, so reject anything that could escape the
# skills directory (e.g. ``../../etc/passwd``).
if not _SAFE_NAME_RE.match(name):
return {
"output": f"错误: 技能名称包含非法字符: {name}(仅允许字母、数字、下划线、连字符)",
"exit_code": 1,
"is_error": True,
}
# Build the install command
if source:
install_target = source
@ -91,8 +106,19 @@ class SkillInstallTool(Tool):
}
try:
# ``--yes`` is an npx flag (NOT a skills flag) that auto-accepts
# the "Need to install the following packages: skills@x.y.z.
# Ok to proceed?" prompt. Without it, npx blocks waiting for
# stdin in the non-interactive subprocess and the install fails
# with "npm error canceled". The trailing ``-y`` is the skills
# CLI's own confirm flag.
proc = await asyncio.create_subprocess_exec(
"npx", "skills@latest", "install", install_target, "-y",
"npx",
"--yes",
"skills@latest",
"install",
install_target,
"-y",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@ -136,13 +162,27 @@ class SkillInstallTool(Tool):
}
def _try_register_skill(self, name: str) -> str:
"""Try to find and register the installed skill YAML into skill_registry."""
"""Try to find and register the installed skill YAML/SKILL.md into skill_registry.
``npx skills add`` installs skills as ``{skills_dir}/{name}/SKILL.md``
(a directory containing a markdown-frontmatter file), NOT as a
flat ``{name}.yaml``. This method therefore checks both layouts:
1. Flat YAML: ``{skills_dir}/{name}.yaml`` (legacy / hand-authored)
2. Directory with SKILL.md: ``{skills_dir}/{name}/SKILL.md`` (npx skills)
3. Directory with any .yaml/.yml: ``{skills_dir}/{name}/*.yaml`` (fallback)
"""
try:
from agentkit.skills.loader import SkillLoader
for search_dir in [os.path.join(os.getcwd(), ".agents", "skills"),
search_dirs = [
os.path.join(os.getcwd(), ".agents", "skills"),
os.path.join(os.path.expanduser("~"), ".agents", "skills"),
os.path.join(os.getcwd(), "configs", "skills")]:
os.path.join(os.getcwd(), "configs", "skills"),
]
for search_dir in search_dirs:
# Layout 1: flat {name}.yaml
yaml_path = os.path.join(search_dir, f"{name}.yaml")
if os.path.exists(yaml_path):
loader = SkillLoader(
@ -152,12 +192,22 @@ class SkillInstallTool(Tool):
loader.load_from_file(yaml_path)
return f"技能已注册到系统(来源: {yaml_path}"
# Also check for directory-based skills
for search_dir in [os.path.join(os.getcwd(), ".agents", "skills"),
os.path.join(os.path.expanduser("~"), ".agents", "skills")]:
# Layout 2 & 3: directory-based skills ({name}/SKILL.md or {name}/*.yaml)
for search_dir in search_dirs:
skill_dir = os.path.join(search_dir, name)
if os.path.isdir(skill_dir):
for fname in os.listdir(skill_dir):
if not os.path.isdir(skill_dir):
continue
# Prefer SKILL.md (the format npx skills actually produces)
md_path = os.path.join(skill_dir, "SKILL.md")
if os.path.isfile(md_path):
loader = SkillLoader(
skill_registry=self._skill_registry,
tool_registry=self._tool_registry,
)
loader.load_from_skill_md(md_path)
return f"技能已注册到系统(来源: {md_path}"
# Fallback: any YAML file inside the directory
for fname in sorted(os.listdir(skill_dir)):
if fname.endswith((".yaml", ".yml")):
yaml_path = os.path.join(skill_dir, fname)
loader = SkillLoader(
@ -167,7 +217,7 @@ class SkillInstallTool(Tool):
loader.load_from_file(yaml_path)
return f"技能已注册到系统(来源: {yaml_path}"
return "技能文件已下载,但未找到 YAML 配置文件进行注册。可能需要重启服务。"
return "技能文件已下载,但未找到 YAML/SKILL.md 配置文件进行注册。可能需要重启服务。"
except Exception as e:
logger.warning(f"Failed to register skill {name}: {e}")
return f"技能文件已下载,但注册失败: {e}"

View File

@ -62,8 +62,16 @@ class SkillSearchTool(Tool):
}
try:
# ``--yes`` is an npx flag that auto-accepts npx's own
# "Need to install the following packages: skills@x.y.z.
# Ok to proceed?" prompt. Without it, npx blocks waiting
# for stdin in the non-interactive subprocess.
proc = await asyncio.create_subprocess_exec(
"npx", "skills@latest", "search", keyword,
"npx",
"--yes",
"skills@latest",
"search",
keyword,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@ -108,8 +116,7 @@ class SkillSearchTool(Tool):
except FileNotFoundError:
return {
"output": (
"npx 命令未找到,请确保 Node.js 已安装。\n"
"安装方式: https://nodejs.org/"
"npx 命令未找到,请确保 Node.js 已安装。\n安装方式: https://nodejs.org/"
),
"exit_code": -1,
"is_error": True,
@ -126,6 +133,7 @@ class SkillSearchTool(Tool):
"""Format raw npx skills search output into a readable result."""
# Clean up ANSI escape codes
import re
clean = re.sub(r"\x1b\[[0-9;]*m", "", raw_output)
if not clean.strip():