fix: Tauri reload, multi-conv blocking, skill install, UI polish
Deploy to Production / deploy (push) Waiting to run
Details
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:
parent
7e0ef6d1ac
commit
bc424574c7
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 server→client 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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in New Issue