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>
|
</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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 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)
|
// 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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue