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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { ConfigProvider as AConfigProvider } from 'ant-design-vue' import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN' import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useThemeStore } from './stores/theme' import { useThemeStore } from './stores/theme'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { isTauri, startBackend, checkBackendHealth } from './api/tauri' import { isTauri } from './api/tauri'
import { import {
initApiBaseURL,
setTokenProvider, setTokenProvider,
setRefreshProvider, setRefreshProvider,
setUnauthorizedHandler, setUnauthorizedHandler,
@ -32,8 +31,10 @@ const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
const loading = ref(isTauri()) const loading = ref(isTauri())
const loadingStatus = ref('正在初始化...')
const loadError = ref('') const loadError = ref('')
// Mirror the auth store's bootstrap progress so the SplashScreen shows
// "..." / "..." etc.
const loadingStatus = computed(() => authStore.bootstrapStatus || '正在初始化...')
onMounted(async () => { onMounted(async () => {
// Wire the auth store into the API client so every request gets a JWT // Wire the auth store into the API client so every request gets a JWT
@ -49,51 +50,43 @@ onMounted(async () => {
router.replace({ name: 'login' }) router.replace({ name: 'login' })
}) })
// Cold-start auth: rehydrate from the persisted refresh token. // The cold-start (Tauri backend bootstrap + whoami) was kicked off in
// This populates user + access token WITHOUT forcing a login. // ``main.ts`` before mount. ``router.beforeEach`` already awaited it,
// The router guard then decides whether to keep the user where // so by the time we get here the startup state is final. We still
// they are or redirect to /login. // await ``waitForStartup()`` for the SplashScreen to transition
await authStore.startupCheck() // correctly based on the result.
if (isTauri()) { if (isTauri()) {
try { const result = await authStore.waitForStartup()
await bootstrapBackend() if (result === 'valid') {
} catch (err: unknown) { loading.value = false
loadError.value = err instanceof Error ? err.message : String(err) } 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 { } else {
// Non-Tauri (browser): the router guard awaited the probe too;
// no splash needed.
loading.value = false 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. */ /** User-triggered retry from the error screen. */
async function retryBootstrap(): Promise<void> { async function retryBootstrap(): Promise<void> {
loadError.value = '' loadError.value = ''
loading.value = true loading.value = true
try { const result = await authStore.retryStartup()
await bootstrapBackend() if (result === 'valid') {
} catch (err: unknown) { loading.value = false
loadError.value = err instanceof Error ? err.message : String(err) } 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> </script>
@ -114,7 +107,7 @@ html, body, #app {
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 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 Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji'; 'Noto Sans Emoji';
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: var(--text-primary, #1a1a1a); color: var(--text-primary, #1a1a1a);

View File

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

View File

@ -35,7 +35,7 @@
</template> </template>
<script setup lang="ts"> <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 MarkdownIt from 'markdown-it'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { Tag as ATag, Spin as ASpin, message as antMessage } from 'ant-design-vue' 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 return props.message.role === 'assistant' && props.message.matched_skill
}) })
const renderedContent = computed(() => { /**
if (!props.message.content) return '' * When the assistant is still streaming, defer re-parsing the markdown by a
return sanitize(md.render(props.message.content)) * 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 ?? []) const toolCalls = computed(() => props.message.tool_calls ?? [])
@ -298,6 +346,41 @@ watch(renderedContent, () => {
margin-bottom: var(--space-3); 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(h1),
.assistant-text__markdown :deep(h2), .assistant-text__markdown :deep(h2),
.assistant-text__markdown :deep(h3) { .assistant-text__markdown :deep(h3) {

View File

@ -4,10 +4,19 @@ import 'ant-design-vue/dist/reset.css'
import './styles' import './styles'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useAuthStore } from './stores/auth'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia()
app.use(createPinia()) app.use(pinia)
app.use(router) 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') app.mount('#app')

View File

@ -214,21 +214,26 @@ const router = createRouter({
* users are redirected to /login with a ``redirect`` query param * users are redirected to /login with a ``redirect`` query param
* preserving the original target. * preserving the original target.
* *
* Note: the cold-start auth rehydration happens in App.vue's * CRITICAL: ``router.beforeEach`` runs BEFORE ``App.vue::onMounted``.
* ``onMounted`` (calls ``authStore.startupCheck()``) BEFORE this guard * On a Tauri reload the access token is gone (memory-only) and
* runs, so by the time the guard executes the store's * ``startupState`` is still ``'pending'`` because ``startupCheck()``
* ``isAuthenticated`` is in its true post-startup state. * 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 * - 'valid' authenticated, allow
* - 'invalid' not authenticated, redirect to /login * - 'invalid' not authenticated, redirect to /login
* - 'error' server unreachable, redirect to /login with a * - 'error' backend unreachable / health check failed; allow the
* "正在重试" hint * navigation through so ``App.vue``'s SplashScreen can
* - 'pending' still resolving; allow the navigation (the store * surface the error + retry button (the splash overlays
* hasn't blocked it yet) so the user doesn't see a * ``<router-view>`` so no protected content leaks).
* flash of /login
*/ */
router.beforeEach((to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const title = to.meta.title as string | undefined const title = to.meta.title as string | undefined
if (title) { if (title) {
document.title = `${title} - Fischer AgentKit` document.title = `${title} - Fischer AgentKit`
@ -241,6 +246,18 @@ router.beforeEach((to, _from, next) => {
} }
const authStore = useAuthStore() 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) { if (!authStore.isAuthenticated) {
// Preserve the original target so we can redirect after login // Preserve the original target so we can redirect after login
next({ next({

View File

@ -25,6 +25,8 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { authApi, type IAuthUser, type ITokenPair, type ISessionInfo } from '@/api/auth' import { authApi, type IAuthUser, type ITokenPair, type ISessionInfo } from '@/api/auth'
import { tauriAuthStorage } from '@/api/tauri-auth' import { tauriAuthStorage } from '@/api/tauri-auth'
import { isTauri, startBackend, checkBackendHealth } from '@/api/tauri'
import { initApiBaseURL } from '@/api/base'
const USER_KEY = 'agentkit.user' const USER_KEY = 'agentkit.user'
@ -342,9 +344,79 @@ export const useAuthStore = defineStore('auth', () => {
startupState.value = 'invalid' 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). */ /** Force re-evaluation of the startup state (e.g. after a manual retry). */
async function retryStartup(): Promise<AuthStartupState> { 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) --- // --- Session management (U4/U8 surface) ---
@ -392,6 +464,7 @@ export const useAuthStore = defineStore('auth', () => {
error, error,
sessionId, sessionId,
startupState, startupState,
bootstrapStatus,
// getters // getters
isAuthenticated, isAuthenticated,
role, role,
@ -404,6 +477,8 @@ export const useAuthStore = defineStore('auth', () => {
logoutLocal, logoutLocal,
silentRefresh, silentRefresh,
startupCheck, startupCheck,
beginStartup,
waitForStartup,
retryStartup, retryStartup,
listSessions, listSessions,
revokeSession, revokeSession,

View File

@ -14,14 +14,126 @@ function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` 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', () => { export const useChatStore = defineStore('chat', () => {
// --- State --- // --- State ---
const conversations = ref<IConversation[]>([]) const conversations = ref<IConversation[]>([])
const currentConversationId = ref<string | null>(null) const currentConversationId = ref<string | null>(null)
const isLoading = ref(false)
const isWsConnected = ref(false) const isWsConnected = ref(false)
const ws = ref<WebSocket | null>(null) 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) // Board Meeting state (transient, only active during a board discussion)
const boardState = ref<{ const boardState = ref<{
@ -43,6 +155,25 @@ export const useChatStore = defineStore('chat', () => {
return currentConversation.value?.messages ?? [] 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 --- // --- Actions ---
/** Load all conversations from the server */ /** Load all conversations from the server */
@ -66,7 +197,8 @@ export const useChatStore = defineStore('chat', () => {
/** Select a conversation by ID and load its messages */ /** Select a conversation by ID and load its messages */
async function selectConversation(id: string, force = false): Promise<void> { async function selectConversation(id: string, force = false): Promise<void> {
currentConversationId.value = id 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) // Load full conversation with messages if not already loaded (or when forced)
const conv = conversations.value.find((c) => c.id === id) const conv = conversations.value.find((c) => c.id === id)
@ -121,7 +253,8 @@ export const useChatStore = defineStore('chat', () => {
} }
conversations.value.unshift(newConversation) conversations.value.unshift(newConversation)
currentConversationId.value = newConversation.id currentConversationId.value = newConversation.id
streamingSteps.value = [] // New conversation starts with empty steps.
clearConvSteps(newConversation.id)
} }
/** Send a message using REST API (fallback) */ /** Send a message using REST API (fallback) */
@ -151,7 +284,7 @@ export const useChatStore = defineStore('chat', () => {
} }
appendMessage(conversationId, assistantMessage) appendMessage(conversationId, assistantMessage)
isLoading.value = true markConversationPending(conversationId)
try { try {
const request: IChatRequest = { const request: IChatRequest = {
@ -182,7 +315,7 @@ export const useChatStore = defineStore('chat', () => {
status: 'completed', status: 'completed',
}) })
} finally { } finally {
isLoading.value = false markConversationDone(conversationId)
} }
} }
@ -220,8 +353,8 @@ export const useChatStore = defineStore('chat', () => {
} }
appendMessage(conversationId, assistantMessage) appendMessage(conversationId, assistantMessage)
isLoading.value = true markConversationPending(conversationId)
streamingSteps.value = [] clearConvSteps(conversationId)
const wsMessage: WsClientMessage = { const wsMessage: WsClientMessage = {
type: 'chat', type: 'chat',
@ -243,7 +376,7 @@ export const useChatStore = defineStore('chat', () => {
(m) => m.id !== userMessage.id && m.id !== assistantMessage.id, (m) => m.id !== userMessage.id && m.id !== assistantMessage.id,
) )
} }
isLoading.value = false markConversationDone(conversationId)
await sendMessage(message, sources) await sendMessage(message, sources)
return return
} }
@ -295,10 +428,14 @@ export const useChatStore = defineStore('chat', () => {
socket.onclose = () => { socket.onclose = () => {
isWsConnected.value = false isWsConnected.value = false
// P2 #21 fix: reset isLoading to prevent stuck loading state during // P2 #21 fix: clear per-conversation pending state to prevent stuck
// disconnect. _recoverTaskAfterReconnect will re-set it if an active // loading state during disconnect. _recoverTaskAfterReconnect will
// task is found after reconnection. // re-mark conversations pending if an active task is found.
isLoading.value = false 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') console.log('WebSocket disconnected')
if (_heartbeatTimer) { if (_heartbeatTimer) {
clearInterval(_heartbeatTimer) clearInterval(_heartbeatTimer)
@ -352,10 +489,13 @@ export const useChatStore = defineStore('chat', () => {
* needs to send the cancel message and guard against send failures. * needs to send the cancel message and guard against send failures.
*/ */
function stopGeneration(): void { function stopGeneration(): void {
const cid = currentConversationId.value
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) { if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
// No open socket — just reset the loading flag locally. // No open socket — just clear the current conversation's pending state.
isLoading.value = false if (cid) {
streamingSteps.value = [] markConversationDone(cid)
clearConvSteps(cid)
}
return return
} }
try { try {
@ -363,8 +503,10 @@ export const useChatStore = defineStore('chat', () => {
ws.value.send(JSON.stringify(cancelMsg)) ws.value.send(JSON.stringify(cancelMsg))
} catch (error) { } catch (error) {
console.error('Failed to send cancel message:', error) console.error('Failed to send cancel message:', error)
isLoading.value = false if (cid) {
streamingSteps.value = [] 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 // Problem 5: only mark conversation pending if we can actually send
isLoading.value = true // the resume
const cid = currentConversationId.value
if (cid) markConversationPending(cid)
try { try {
ws.value.send( ws.value.send(
JSON.stringify({ JSON.stringify({
@ -414,7 +558,7 @@ export const useChatStore = defineStore('chat', () => {
) )
} catch (error) { } catch (error) {
console.error('Failed to send resume message:', error) console.error('Failed to send resume message:', error)
isLoading.value = false if (cid) markConversationDone(cid)
} }
} else { } else {
// No active task — force reload conversation messages (may include // No active task — force reload conversation messages (may include
@ -458,7 +602,7 @@ export const useChatStore = defineStore('chat', () => {
} }
case 'routing': { case 'routing': {
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break if (!conv) break
@ -472,12 +616,17 @@ export const useChatStore = defineStore('chat', () => {
routing_method: data.method, 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 break
} }
case 'step': { case 'step': {
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break if (!conv) break
@ -486,14 +635,96 @@ export const useChatStore = defineStore('chat', () => {
.find((m) => m.role === 'assistant') .find((m) => m.role === 'assistant')
const stepInfo = data.data const stepInfo = data.data
const innerData = stepInfo.data as Record<string, unknown> const innerData = stepInfo.data as Record<string, unknown>
const desc = stepInfo.event_type === 'final_answer' const eventType = stepInfo.event_type as string | undefined
? '生成最终回答' // Aggregate token deltas into a single "streaming" step. We only push
: stepInfo.event_type === 'tool_call' // a new step on transitions between non-token types, otherwise the
? `调用工具: ${(innerData.tool_name || innerData.name || '#') as string}` // list explodes with one entry per token.
: stepInfo.event_type === 'thinking' if (eventType === 'token' || eventType === 'turn.token') {
? '思考中...' const chunk = (innerData.delta ?? innerData.output ?? innerData.content ?? '') as string
: `步骤 ${stepInfo.step || ''}: ${stepInfo.event_type || ''}` const deltaLen = chunk?.length ?? 0
streamingSteps.value.push(desc) 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 // Track tool calls for ToolCallCard rendering
if (lastAssistantMsg) { if (lastAssistantMsg) {
@ -553,7 +784,7 @@ export const useChatStore = defineStore('chat', () => {
} }
case 'result': { case 'result': {
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break if (!conv) break
@ -577,13 +808,16 @@ export const useChatStore = defineStore('chat', () => {
? `${firstUserMsg.content.substring(0, 20)}...` ? `${firstUserMsg.content.substring(0, 20)}...`
: firstUserMsg.content : firstUserMsg.content
} }
isLoading.value = false markConversationDone(conversationId)
streamingSteps.value = [] // 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 break
} }
case 'error': { case 'error': {
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break if (!conv) break
@ -609,22 +843,29 @@ export const useChatStore = defineStore('chat', () => {
} }
appendMessage(conversationId, errorMsg) appendMessage(conversationId, errorMsg)
} }
isLoading.value = false markConversationDone(conversationId)
streamingSteps.value = [] clearConvSteps(conversationId)
break break
} }
case 'team_formed': { case 'team_formed': {
const conversationId = resolveIncomingConvId()
if (!conversationId) break
const teamStore = _getTeamStore() const teamStore = _getTeamStore()
if (teamStore) { if (teamStore) {
teamStore.setTeamState(data.data) 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 break
} }
case 'expert_step': { case 'expert_step': {
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break if (!conv) break
@ -650,12 +891,17 @@ export const useChatStore = defineStore('chat', () => {
} }
appendMessage(conversationId, expertMsg) 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 break
} }
case 'expert_result': { case 'expert_result': {
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break if (!conv) break
@ -679,7 +925,7 @@ export const useChatStore = defineStore('chat', () => {
if (teamStore) { if (teamStore) {
teamStore.updatePhases(data.data.plan_phases) teamStore.updatePhases(data.data.plan_phases)
} }
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break if (!conv) break
@ -706,7 +952,7 @@ export const useChatStore = defineStore('chat', () => {
} }
case 'team_synthesis': { case 'team_synthesis': {
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break if (!conv) break
@ -727,7 +973,14 @@ export const useChatStore = defineStore('chat', () => {
if (teamStore) { if (teamStore) {
teamStore.clearTeam() teamStore.clearTeam()
} }
streamingSteps.value.push('专家团队已解散') const cid = resolveIncomingConvId()
if (cid) {
appendStep({
type: 'team_event',
label: '专家团队已解散',
status: 'success',
}, cid)
}
break break
} }
@ -735,7 +988,15 @@ export const useChatStore = defineStore('chat', () => {
const teamStore = _getTeamStore() const teamStore = _getTeamStore()
if (teamStore?.teamState) { if (teamStore?.teamState) {
teamStore.updatePhaseStatus(data.data.phase_id, 'in_progress') 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 break
} }
@ -744,7 +1005,15 @@ export const useChatStore = defineStore('chat', () => {
const teamStore = _getTeamStore() const teamStore = _getTeamStore()
if (teamStore?.teamState) { if (teamStore?.teamState) {
teamStore.updatePhaseStatus(data.data.phase_id, 'completed', data.data.result_summary) 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 break
} }
@ -753,7 +1022,15 @@ export const useChatStore = defineStore('chat', () => {
const teamStore = _getTeamStore() const teamStore = _getTeamStore()
if (teamStore?.teamState) { if (teamStore?.teamState) {
teamStore.updatePhaseStatus(data.data.phase_id, 'failed', data.data.error) 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 break
} }
@ -776,12 +1053,15 @@ export const useChatStore = defineStore('chat', () => {
current_round: 0, current_round: 0,
status: 'discussing', status: 'discussing',
} }
streamingSteps.value.push(
`私董会已开启: 主题「${boardData.topic}」, ${boardData.experts.length} 位专家, 最多 ${boardData.max_rounds}`
)
// Push a structured banner message so the renderer can show BoardBannerCard // Push a structured banner message so the renderer can show BoardBannerCard
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (conversationId) { if (conversationId) {
appendStep({
type: 'board_event',
label: '私董会开启',
detail: `主题「${boardData.topic}」 · ${boardData.experts.length} 位专家 · 最多 ${boardData.max_rounds}`,
status: 'success',
}, conversationId)
const startMsg: IChatMessage = { const startMsg: IChatMessage = {
id: generateId(), id: generateId(),
role: 'assistant', role: 'assistant',
@ -803,7 +1083,7 @@ export const useChatStore = defineStore('chat', () => {
if (boardState.value && speechData.round > boardState.value.current_round) { if (boardState.value && speechData.round > boardState.value.current_round) {
boardState.value.current_round = speechData.round boardState.value.current_round = speechData.round
} }
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const speechMsg: IChatMessage = { const speechMsg: IChatMessage = {
id: generateId(), id: generateId(),
@ -819,15 +1099,18 @@ export const useChatStore = defineStore('chat', () => {
board_role: speechData.role, board_role: speechData.role,
} }
appendMessage(conversationId, speechMsg) appendMessage(conversationId, speechMsg)
streamingSteps.value.push( appendStep({
`${speechData.expert_avatar} ${speechData.expert_name} (第${speechData.round}${speechData.role === 'moderator' ? '·主持' : ''})` type: 'board_event',
) label: `${speechData.expert_avatar || ''} ${speechData.expert_name}`,
detail: `${speechData.round}${speechData.role === 'moderator' ? ' · 主持' : ''}`,
status: 'success',
}, conversationId)
break break
} }
case 'round_summary': { case 'round_summary': {
const summaryData = data.data const summaryData = data.data
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (!conversationId) break if (!conversationId) break
const summaryMsg: IChatMessage = { const summaryMsg: IChatMessage = {
id: generateId(), id: generateId(),
@ -841,13 +1124,26 @@ export const useChatStore = defineStore('chat', () => {
board_role: 'summary', board_role: 'summary',
} }
appendMessage(conversationId, summaryMsg) appendMessage(conversationId, summaryMsg)
streamingSteps.value.push(`${summaryData.round}轮小结${summaryData.continue ? '(继续讨论)' : '(即将结束)'}`) appendStep({
type: 'board_event',
label: `${summaryData.round} 轮小结`,
detail: summaryData.continue ? '继续讨论' : '即将结束',
status: 'success',
}, conversationId)
break break
} }
case 'user_intervention': { case 'user_intervention': {
const interventionData = data.data 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 break
} }
@ -857,12 +1153,15 @@ export const useChatStore = defineStore('chat', () => {
if (boardState.value) { if (boardState.value) {
boardState.value.status = 'completed' boardState.value.status = 'completed'
} }
streamingSteps.value.push(
`私董会结束: ${conclusionData.total_rounds} 轮讨论${conclusionData.error ? ' (异常)' : ''}`
)
// Push a structured conclusion message so the renderer can show BoardConclusionCard // Push a structured conclusion message so the renderer can show BoardConclusionCard
const conversationId = currentConversationId.value const conversationId = resolveIncomingConvId()
if (conversationId) { if (conversationId) {
appendStep({
type: 'board_event',
label: '私董会结束',
detail: `${conclusionData.total_rounds}${conclusionData.error ? ' · 异常' : ''}`,
status: conclusionData.error ? 'error' : 'success',
}, conversationId)
const conclusionMsg: IChatMessage = { const conclusionMsg: IChatMessage = {
id: generateId(), id: generateId(),
role: 'assistant', role: 'assistant',
@ -912,7 +1211,7 @@ export const useChatStore = defineStore('chat', () => {
async function resendLastUserMessage(): Promise<void> { async function resendLastUserMessage(): Promise<void> {
const conversationId = currentConversationId.value const conversationId = currentConversationId.value
if (!conversationId) return if (!conversationId) return
if (isLoading.value) return if (pendingConversations.value.has(conversationId)) return
const conv = conversations.value.find((c) => c.id === conversationId) const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) return if (!conv) return
const lastUserMsg = [...conv.messages] const lastUserMsg = [...conv.messages]
@ -928,13 +1227,20 @@ export const useChatStore = defineStore('chat', () => {
// State // State
conversations, conversations,
currentConversationId, currentConversationId,
isLoading,
isWsConnected, isWsConnected,
streamingSteps, ws,
pendingConversations,
streamingStepsByConv,
boardState, boardState,
// Legacy aliases (derive from current conversation for backward compat).
// New code should use `isCurrentLoading` / `currentStreamingSteps` instead.
isLoading: isCurrentLoading,
streamingSteps: currentStreamingSteps,
// Getters // Getters
currentConversation, currentConversation,
currentMessages, currentMessages,
isCurrentLoading,
currentStreamingSteps,
isBoardMode, isBoardMode,
// Actions // Actions
loadConversations, loadConversations,

View File

@ -41,15 +41,36 @@
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
/> />
<!-- Streaming steps --> <!-- Streaming steps (structured) -->
<div v-if="chatStore.streamingSteps.length > 0" class="chat-view__steps"> <div v-if="chatStore.streamingSteps.length > 0" class="chat-view__steps">
<a-typography-text type="secondary"> <div class="chat-view__steps-header">
<LoadingOutlined /> 处理中... <a-typography-text type="secondary">
</a-typography-text> <LoadingOutlined /> 执行中
<div v-for="(step, idx) in chatStore.streamingSteps" :key="idx" class="chat-view__step"> </a-typography-text>
<RightOutlined class="chat-view__step-icon" /> <span class="chat-view__steps-count">{{ chatStore.streamingSteps.length }} </span>
<span>{{ step }}</span>
</div> </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> </div>
</div> </div>
@ -76,7 +97,8 @@ import {
PlusOutlined, PlusOutlined,
RobotOutlined, RobotOutlined,
LoadingOutlined, LoadingOutlined,
RightOutlined, CheckOutlined,
CloseOutlined,
ThunderboltOutlined, ThunderboltOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
@ -254,12 +276,40 @@ function handleSend(message: string, model?: string): void {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm); 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 { .chat-view__input-wrap {
padding: var(--space-3) var(--space-4) var(--space-4); padding: var(--space-3) var(--space-4) var(--space-4);
background: var(--bg-primary); background: var(--bg-primary);
border-top: 1px solid var(--border-color);
} }
.chat-view__input-inner { .chat-view__input-inner {
@ -271,14 +321,67 @@ function handleSend(message: string, model?: string): void {
.chat-view__step { .chat-view__step {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-2);
padding: 2px 0; padding: 4px 0;
font-size: var(--font-sm); font-size: var(--font-sm);
color: var(--text-secondary); color: var(--text-secondary);
} }
.chat-view__step-icon { .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); 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> </style>

View File

@ -195,9 +195,15 @@ def _resolve_jwt_secret(request: Request) -> str:
async def _ensure_db(request: Request) -> Path: 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) db_path = _resolve_db_path(request)
if not db_path.exists(): await init_auth_db(db_path)
await init_auth_db(db_path)
return db_path return db_path

View File

@ -3,12 +3,17 @@
import asyncio import asyncio
import logging import logging
import os import os
import re
from typing import Any, Callable, Awaitable from typing import Any, Callable, Awaitable
from agentkit.tools.base import Tool from agentkit.tools.base import Tool
logger = logging.getLogger(__name__) 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): class SkillInstallTool(Tool):
"""技能安装工具 """技能安装工具
@ -74,6 +79,16 @@ class SkillInstallTool(Tool):
"is_error": True, "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 # Build the install command
if source: if source:
install_target = source install_target = source
@ -91,8 +106,19 @@ class SkillInstallTool(Tool):
} }
try: 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( 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, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@ -136,13 +162,27 @@ class SkillInstallTool(Tool):
} }
def _try_register_skill(self, name: str) -> str: 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: try:
from agentkit.skills.loader import SkillLoader from agentkit.skills.loader import SkillLoader
for search_dir in [os.path.join(os.getcwd(), ".agents", "skills"), search_dirs = [
os.path.join(os.path.expanduser("~"), ".agents", "skills"), os.path.join(os.getcwd(), ".agents", "skills"),
os.path.join(os.getcwd(), "configs", "skills")]: os.path.join(os.path.expanduser("~"), ".agents", "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") yaml_path = os.path.join(search_dir, f"{name}.yaml")
if os.path.exists(yaml_path): if os.path.exists(yaml_path):
loader = SkillLoader( loader = SkillLoader(
@ -152,22 +192,32 @@ class SkillInstallTool(Tool):
loader.load_from_file(yaml_path) loader.load_from_file(yaml_path)
return f"技能已注册到系统(来源: {yaml_path}" return f"技能已注册到系统(来源: {yaml_path}"
# Also check for directory-based skills # Layout 2 & 3: directory-based skills ({name}/SKILL.md or {name}/*.yaml)
for search_dir in [os.path.join(os.getcwd(), ".agents", "skills"), for search_dir in search_dirs:
os.path.join(os.path.expanduser("~"), ".agents", "skills")]:
skill_dir = os.path.join(search_dir, name) skill_dir = os.path.join(search_dir, name)
if os.path.isdir(skill_dir): if not os.path.isdir(skill_dir):
for fname in os.listdir(skill_dir): continue
if fname.endswith((".yaml", ".yml")): # Prefer SKILL.md (the format npx skills actually produces)
yaml_path = os.path.join(skill_dir, fname) md_path = os.path.join(skill_dir, "SKILL.md")
loader = SkillLoader( if os.path.isfile(md_path):
skill_registry=self._skill_registry, loader = SkillLoader(
tool_registry=self._tool_registry, skill_registry=self._skill_registry,
) tool_registry=self._tool_registry,
loader.load_from_file(yaml_path) )
return f"技能已注册到系统(来源: {yaml_path}" 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(
skill_registry=self._skill_registry,
tool_registry=self._tool_registry,
)
loader.load_from_file(yaml_path)
return f"技能已注册到系统(来源: {yaml_path}"
return "技能文件已下载,但未找到 YAML 配置文件进行注册。可能需要重启服务。" return "技能文件已下载,但未找到 YAML/SKILL.md 配置文件进行注册。可能需要重启服务。"
except Exception as e: except Exception as e:
logger.warning(f"Failed to register skill {name}: {e}") logger.warning(f"Failed to register skill {name}: {e}")
return f"技能文件已下载,但注册失败: {e}" return f"技能文件已下载,但注册失败: {e}"

View File

@ -62,8 +62,16 @@ class SkillSearchTool(Tool):
} }
try: 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( proc = await asyncio.create_subprocess_exec(
"npx", "skills@latest", "search", keyword, "npx",
"--yes",
"skills@latest",
"search",
keyword,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@ -108,8 +116,7 @@ class SkillSearchTool(Tool):
except FileNotFoundError: except FileNotFoundError:
return { return {
"output": ( "output": (
"npx 命令未找到,请确保 Node.js 已安装。\n" "npx 命令未找到,请确保 Node.js 已安装。\n安装方式: https://nodejs.org/"
"安装方式: https://nodejs.org/"
), ),
"exit_code": -1, "exit_code": -1,
"is_error": True, "is_error": True,
@ -126,6 +133,7 @@ class SkillSearchTool(Tool):
"""Format raw npx skills search output into a readable result.""" """Format raw npx skills search output into a readable result."""
# Clean up ANSI escape codes # Clean up ANSI escape codes
import re import re
clean = re.sub(r"\x1b\[[0-9;]*m", "", raw_output) clean = re.sub(r"\x1b\[[0-9;]*m", "", raw_output)
if not clean.strip(): if not clean.strip():