This commit is contained in:
2026-06-10 07:55:16 +08:00
parent 821eca9f0c
commit aaac7e9d98
31 changed files with 548 additions and 149 deletions
+15 -11
View File
@@ -13,7 +13,6 @@ export interface Conversation {
model_id: string
system_prompt: string
title?: string | null
title_generated_at?: string | null
created_at: string
updated_at: string
}
@@ -24,6 +23,7 @@ export interface Attachment {
mime_type: string
size_bytes: number
sha256: string
created_at: string
}
export interface ChatMessage {
@@ -98,15 +98,12 @@ const enc = (v: string) => encodeURIComponent(v)
export const chatApi = {
// ===== Conversations =====
listConversations: (params: ListConversationsParams = {}) =>
request<{ items: Conversation[] }>(`/api/v1/conversations${buildQuery(params)}`).then(
(r) => r.items,
),
request<Conversation[]>(`/api/v1/conversations${buildQuery(params)}`),
createConversation: (body: CreateConversationBody) =>
request<Conversation>('/api/v1/conversations', { method: 'POST', body }),
getConversation: (id: string, opts: { before?: number; limit?: number } = {}) =>
getConversation: (id: string, opts: { limit?: number } = {}) =>
request<ConversationDetail>(
`/api/v1/conversations/${enc(id)}${buildQuery({
before: opts.before,
limit: opts.limit ?? 50,
})}`,
),
@@ -127,6 +124,16 @@ export const chatApi = {
method: 'POST',
body,
}),
listMessages: (
id: string,
params: { before_id?: number; limit?: number } = {},
) =>
request<ChatMessage[]>(
`/api/v1/conversations/${enc(id)}/messages${buildQuery({
before_id: params.before_id,
limit: params.limit,
})}`,
),
cancelMessage: (messageId: number) =>
request<void>(`/api/v1/messages/${messageId}/cancel`, { method: 'POST' }),
retryMessage: (messageId: number) =>
@@ -140,8 +147,7 @@ export const chatApi = {
},
// ===== Templates =====
listTemplates: () =>
request<{ items: PromptTemplate[] }>('/api/v1/prompt-templates').then((r) => r.items),
listTemplates: () => request<PromptTemplate[]>('/api/v1/prompt-templates'),
createTemplate: (body: { name: string; content: string; scope: TemplateScope }) =>
request<PromptTemplate>('/api/v1/prompt-templates', { method: 'POST', body }),
updateTemplate: (id: string, body: { name?: string; content?: string }) =>
@@ -151,7 +157,5 @@ export const chatApi = {
// ===== Personal usage =====
myDailyUsage: (from: string, to: string) =>
request<{ items: UsageDailyItem[] }>(
`/api/v1/users/me/usage${buildQuery({ from, to })}`,
).then((r) => r.items),
request<UsageDailyItem[]>(`/api/v1/users/me/usage${buildQuery({ from, to })}`),
}
+5
View File
@@ -11,13 +11,16 @@ interface RequestOptions {
let authTokenProvider: () => string | null = () => null
let onUnauthorized: () => void = () => {}
let onForbidden: () => void = () => {}
export function configure(opts: {
tokenProvider: () => string | null
onUnauthorized: () => void
onForbidden?: () => void
}) {
authTokenProvider = opts.tokenProvider
onUnauthorized = opts.onUnauthorized
if (opts.onForbidden) onForbidden = opts.onForbidden
}
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
@@ -55,6 +58,8 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
if (resp.status === 401) {
onUnauthorized()
} else if (resp.status === 403) {
onForbidden()
}
if (resp.status === 204) {
+4 -6
View File
@@ -7,6 +7,7 @@ export interface LLMEndpoint {
provider: LLMProvider
display_name: string
base_url: string
created_by: string
created_at: string
updated_at: string
}
@@ -81,8 +82,7 @@ const enc = (v: string) => encodeURIComponent(v)
export const llmAdminApi = {
// ===== Endpoints =====
listEndpoints: () =>
request<{ items: LLMEndpoint[] }>('/api/v1/admin/llm-endpoints').then((r) => r.items),
listEndpoints: () => request<LLMEndpoint[]>('/api/v1/admin/llm-endpoints'),
createEndpoint: (body: CreateEndpointBody) =>
request<LLMEndpoint>('/api/v1/admin/llm-endpoints', { method: 'POST', body }),
updateEndpoint: (id: string, body: UpdateEndpointBody) =>
@@ -94,9 +94,7 @@ export const llmAdminApi = {
// ===== Models =====
listModels: (onlyEnabled = false) =>
request<{ items: LLMModel[] }>(
`/api/v1/admin/llm-models${buildQuery({ only_enabled: onlyEnabled })}`,
).then((r) => r.items),
request<LLMModel[]>(`/api/v1/admin/llm-models${buildQuery({ only_enabled: onlyEnabled })}`),
createModel: (body: CreateModelBody) =>
request<LLMModel>('/api/v1/admin/llm-models', { method: 'POST', body }),
updateModel: (id: string, body: UpdateModelBody) =>
@@ -108,5 +106,5 @@ export const llmAdminApi = {
adminUsage: (from: string, to: string, groupBy: AdminUsageGroupBy) =>
request<{ items: AdminUsageItem[] }>(
`/api/v1/admin/usage${buildQuery({ from, to, group_by: groupBy })}`,
),
).then((r) => r.items),
}
+7
View File
@@ -0,0 +1,7 @@
import { buildQuery, request } from './client'
import type { LLMModel } from './llmAdmin'
export const modelsApi = {
list: (onlyEnabled = true) =>
request<LLMModel[]>('/api/v1/models' + buildQuery({ only_enabled: onlyEnabled })),
}
+1
View File
@@ -162,6 +162,7 @@ export const projectsApi = {
description?: string
requirement_number?: number
assignee_id?: string
workspace_id?: string
},
) => request<Issue>(`/api/v1/projects/${enc(slug)}/issues`, { method: 'POST', body }),
getIssue: (slug: string, number: number) =>
-2
View File
@@ -63,8 +63,6 @@ export const runsApi = {
restart: (id: string) =>
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/restart`, { method: 'POST' }),
status: (id: string) => request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/status`),
logs: (id: string, since = 0) =>
request<LogLine[]>(`/api/v1/run-profiles/${enc(id)}/logs?since=${since}`),
}
// openRunLogsWS 打开到日志流的原生 WebSocket。浏览器 WS 不支持自定义 header,
+6
View File
@@ -69,6 +69,12 @@
>
MCP Tokens
</RouterLink>
<RouterLink
:to="{ name: 'admin-acp-sessions' }"
class="text-sm hover:underline"
>
ACP 会话
</RouterLink>
</template>
</nav>
<span
+10 -1
View File
@@ -16,6 +16,7 @@ export interface UseAcpStreamReturn {
sessionStatus: Ref<SessionStatus>
events: Ref<AcpEvent[]>
pendingPermissions: Ref<PermissionRequest[]>
errorMsg: Ref<string | null>
prompt: (text: string, agentSessionID: string) => void
cancel: (agentSessionID: string) => void
reconnect: () => void
@@ -49,6 +50,7 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
const sessionStatus = ref<SessionStatus>('starting')
const events = ref<AcpEvent[]>([])
const pendingPermissions = ref<PermissionRequest[]>([])
const errorMsg = ref<string | null>(null)
const lastEventID = ref(0)
function upsertPermission(p: PermissionRequest) {
@@ -118,6 +120,13 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
return
}
// 客户端错误帧(bad json / 无权限 / 方法不允许 / 转发失败)——单独提示,不进 events 流。
if (obj.kind === 'client_error') {
const msg = typeof obj.message === 'string' ? obj.message : 'client error'
errorMsg.value = msg
return
}
// Initial state event from server
if (obj.kind === 'state') {
const s = (parsed as ServerStateEvent).status
@@ -209,5 +218,5 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
connect()
onScopeDispose(close)
return { status, sessionStatus, events, pendingPermissions, prompt, cancel, reconnect, close }
return { status, sessionStatus, events, pendingPermissions, errorMsg, prompt, cancel, reconnect, close }
}
+5 -1
View File
@@ -11,6 +11,7 @@ export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'err
export interface UseRunStreamReturn {
status: Ref<StreamStatus>
runStatus: Ref<RunState>
exitCode: Ref<number | null>
logs: Ref<LogLine[]>
reconnect: () => void
close: () => void
@@ -24,11 +25,13 @@ interface WireFrame {
text?: string
ts?: string
status?: RunState
exit_code?: number
}
export function useRunStream(profileId: string): UseRunStreamReturn {
const status = ref<StreamStatus>('idle')
const runStatus = ref<RunState>('stopped')
const exitCode = ref<number | null>(null)
const logs = ref<LogLine[]>([])
const lastLogID = ref(0)
@@ -62,6 +65,7 @@ export function useRunStream(profileId: string): UseRunStreamReturn {
if (parsed.kind === 'state') {
if (parsed.status) runStatus.value = parsed.status
exitCode.value = parsed.exit_code ?? null
return
}
if (parsed.kind === 'slow_consumer_disconnect') {
@@ -124,5 +128,5 @@ export function useRunStream(profileId: string): UseRunStreamReturn {
connect()
onScopeDispose(close)
return { status, runStatus, logs, reconnect, close, clear }
return { status, runStatus, exitCode, logs, reconnect, close, clear }
}
+7
View File
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router, { bindAuthToApi } from './router'
import { useAuthStore } from './stores/auth'
import './styles/tailwind.css'
import './styles/tokens.css'
@@ -12,3 +13,9 @@ app.use(createPinia())
app.use(router)
bindAuthToApi()
app.mount('#app')
// 启动后异步校验本地会话(不阻塞首屏渲染):
// 若 token 仍有效则回填最新用户信息;遇到 401 则登出。
useAuthStore()
.validateSession()
.catch(() => {})
+15 -6
View File
@@ -154,6 +154,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/admin/MCPTokenAdminView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/acp-sessions',
name: 'admin-acp-sessions',
component: () => import('@/views/acp/AcpAdminSessionsView.vue'),
meta: { requiresAdmin: true },
},
{
path: '/admin/acp/agent-kinds',
component: () => import('@/views/admin/AgentKindAdminListView.vue'),
@@ -195,13 +201,16 @@ export default router
// 把 auth store 注入 api client(在 router 模块顶层晚于 pinia 安装可能未就绪,所以用懒访问)
export function bindAuthToApi() {
const auth = useAuthStore()
const redirectToLogin = () => {
auth.clearSession()
const current = router.currentRoute.value
const query = current.name === 'login' ? undefined : { next: current.fullPath }
router.replace({ name: 'login', query }).catch(() => {})
}
configure({
tokenProvider: () => auth.token,
onUnauthorized: () => {
auth.clearSession()
const current = router.currentRoute.value
const query = current.name === 'login' ? undefined : { next: current.fullPath }
router.replace({ name: 'login', query }).catch(() => {})
},
onUnauthorized: redirectToLogin,
// 403:会话仍有效但权限不足(如被降级/禁用),同样登出并回登录页。
onForbidden: redirectToLogin,
})
}
+40 -1
View File
@@ -1,5 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { authApi } from '@/api/auth'
import { ApiException } from '@/api/types'
export interface User {
id: string
@@ -69,5 +71,42 @@ export const useAuthStore = defineStore('auth', () => {
safeRemove('user')
}
return { token, user, isAuthenticated, isAdmin, setSession, clearSession }
// setUser 仅更新当前用户信息(不改动 token),用于服务端回填最新资料。
function setUser(u: User) {
user.value = u
safeWrite('user', JSON.stringify(u))
}
// refreshUser 调用 /auth/me 拉取最新用户信息并同步进 store。
// 调用方负责处理异常(如需)。
async function refreshUser(): Promise<void> {
const u = await authApi.me()
setUser(u)
}
// validateSession 在应用启动时校验本地 token 是否仍然有效:
// 有 token 才校验;成功则同步最新用户信息,遇到 401 则登出。
async function validateSession(): Promise<void> {
if (!token.value) return
try {
await refreshUser()
} catch (e: unknown) {
if (e instanceof ApiException && e.status === 401) {
clearSession()
}
// 其它错误(网络等)保留本地会话,避免误登出。
}
}
return {
token,
user,
isAuthenticated,
isAdmin,
setSession,
clearSession,
setUser,
refreshUser,
validateSession,
}
})
+20 -1
View File
@@ -51,7 +51,8 @@ export const useChatStore = defineStore('chat', () => {
detachStream()
const detail = await chatApi.getConversation(id)
currentConversation.value = detail.conversation
messages.value = detail.messages
// 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。
messages.value = [...detail.messages].sort((a, b) => a.id - b.id)
const pending = detail.messages.find((m) => m.status === 'pending')
if (pending) {
streamingMessageId.value = pending.id
@@ -59,6 +60,23 @@ export const useChatStore = defineStore('chat', () => {
}
}
async function loadOlderMessages() {
const conv = currentConversation.value
if (!conv) return
if (messages.value.length === 0) return
const beforeId = messages.value.reduce(
(min, m) => (m.id < min ? m.id : min),
messages.value[0].id,
)
const older = await chatApi.listMessages(conv.id, { before_id: beforeId })
if (older.length === 0) return
const existingIds = new Set(messages.value.map((m) => m.id))
const fresh = older.filter((m) => !existingIds.has(m.id))
if (fresh.length === 0) return
// 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。
messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id)
}
async function sendMessage(content: string, attachmentIds: string[] = []) {
if (!currentConversation.value) return
if (streamingMessageId.value !== null) return
@@ -211,6 +229,7 @@ export const useChatStore = defineStore('chat', () => {
isStreaming,
loadConversations,
openConversation,
loadOlderMessages,
closeCurrent,
sendMessage,
cancelMessage,
+1
View File
@@ -10,6 +10,7 @@ export interface Notification {
link?: string
read_at?: string
created_at: string
metadata?: Record<string, unknown>
}
export const useNotificationsStore = defineStore('notifications', () => {
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAcpStore } from '@/stores/acp'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
const store = useAcpStore()
onMounted(() => {
// all=true → 管理员视图,列出所有用户的会话。
void store.listSessions(true)
})
async function terminate(id: string) {
if (!confirm('Terminate this session?')) return
try {
await store.terminateSession(id)
} catch (e) {
alert(e instanceof Error ? e.message : 'failed')
}
}
</script>
<template>
<div class="p-6 space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">All ACP Sessions</h1>
<span class="text-sm text-gray-500">Global admin view (all users)</span>
</div>
<div v-if="store.loading" class="text-gray-500">Loading...</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else-if="store.sessions.length === 0" class="text-gray-500">
No sessions.
</div>
<table v-else class="w-full text-sm">
<thead class="text-left">
<tr class="border-b">
<th class="px-3 py-2">User</th>
<th class="px-3 py-2">Branch</th>
<th class="px-3 py-2">Status</th>
<th class="px-3 py-2">Started</th>
<th class="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="s in store.sessions" :key="s.id" class="border-t hover:bg-gray-50">
<td class="px-3 py-2 font-mono text-xs">{{ s.user_id }}</td>
<td class="px-3 py-2 font-mono text-xs">{{ s.branch }}</td>
<td class="px-3 py-2"><SessionStatusBadge :status="s.status" /></td>
<td class="px-3 py-2 text-xs text-gray-500">
{{ new Date(s.started_at).toLocaleString() }}
</td>
<td class="px-3 py-2 text-right space-x-2">
<button
v-if="s.status === 'starting' || s.status === 'running'"
class="text-red-600 hover:underline"
@click="terminate(s.id)"
>
Terminate
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -26,6 +26,8 @@ const events = computed<AcpEvent[]>(() => stream.value?.events.value ?? [])
const pendingPermissions = computed<PermissionRequest[]>(
() => stream.value?.pendingPermissions.value ?? [],
)
// 后端通过 WS 推送的 client_error 帧(如越权 prompt / 方法不允许 / 转发失败)。
const streamError = computed<string | null>(() => stream.value?.errorMsg.value ?? null)
// 仅 session owner(或 admin)可决策;后端同样强制。
const canDecide = computed(
() => auth.user?.is_admin === true || store.currentSession?.user_id === auth.user?.id,
@@ -178,6 +180,11 @@ async function openCommit() {
{{ errorMsg }}
</p>
<!-- WS client_error 帧提示 -->
<p v-if="streamError" class="px-4 py-2 text-sm text-red-700 bg-red-100">
连接错误{{ streamError }}
</p>
<!-- 待审批工具调用 -->
<PermissionApprovalPanel
:requests="pendingPermissions"
+1 -2
View File
@@ -105,8 +105,7 @@ async function reload() {
const from = new Date(r[0]).toISOString()
const to = new Date(r[1]).toISOString()
if (props.adminMode) {
const data = await llmAdminApi.adminUsage(from, to, groupBy.value)
rows.value = data.items
rows.value = await llmAdminApi.adminUsage(from, to, groupBy.value)
} else {
rows.value = await chatApi.myDailyUsage(from, to)
}
+4
View File
@@ -294,6 +294,10 @@ async function toggleRole(row: AdminUser) {
try {
await userAdminApi.setRole(row.id, !row.is_admin)
msg.success('已更新角色')
// 若修改的是当前登录用户,刷新本地会话以同步 isAdmin,避免缓存陈旧。
if (row.id === auth.user?.id) {
await auth.refreshUser().catch(() => {})
}
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '操作失败')
+3 -2
View File
@@ -108,7 +108,8 @@ import { useChatStore } from '@/stores/chat'
import MessageBubble from './components/MessageBubble.vue'
import AttachmentUploader from './components/AttachmentUploader.vue'
import ConversationCreateModal from './ConversationCreateModal.vue'
import { llmAdminApi, type LLMModel, type ModelCapabilities } from '@/api/llmAdmin'
import { modelsApi } from '@/api/models'
import type { LLMModel, ModelCapabilities } from '@/api/llmAdmin'
const chat = useChatStore()
const route = useRoute()
@@ -142,7 +143,7 @@ const currentModelCaps = computed<ModelCapabilities>(() => {
})
onMounted(async () => {
allModels.value = await llmAdminApi.listModels(true)
allModels.value = await modelsApi.list(true)
await chat.loadConversations({ project_id: projectId.value })
const cid = route.params.conversationId
if (typeof cid === 'string' && cid !== '') {
@@ -19,7 +19,8 @@
import { computed, onMounted, ref } from 'vue'
import { NSelect } from 'naive-ui'
import { llmAdminApi, type LLMModel } from '@/api/llmAdmin'
import { modelsApi } from '@/api/models'
import type { LLMModel } from '@/api/llmAdmin'
const props = defineProps<{ modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
@@ -27,7 +28,7 @@ const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
const models = ref<LLMModel[]>([])
onMounted(async () => {
models.value = await llmAdminApi.listModels(true)
models.value = await modelsApi.list(true)
})
const options = computed(() =>
+112 -2
View File
@@ -29,6 +29,9 @@
{{ store.current.slug }}
</p>
</div>
<NButton @click="openEdit">
编辑
</NButton>
</div>
<!-- Description -->
@@ -63,15 +66,79 @@
项目不存在或无权访问
</div>
</NSpin>
<!-- Edit project modal -->
<NModal
v-model:show="showEdit"
preset="card"
title="编辑项目"
class="w-full max-w-md"
>
<NForm @submit.prevent="onEditSubmit">
<NFormItem label="名称">
<NInput
v-model:value="editForm.name"
placeholder="My Project"
/>
</NFormItem>
<NFormItem label="描述">
<NInput
v-model:value="editForm.description"
type="textarea"
:rows="3"
placeholder="(可选)"
/>
</NFormItem>
<NFormItem label="可见性">
<NRadioGroup v-model:value="editForm.visibility">
<NRadio value="private">
私有
</NRadio>
<NRadio value="internal">
内部
</NRadio>
</NRadioGroup>
</NFormItem>
<div class="flex justify-end gap-2 mt-4">
<NButton @click="showEdit = false">
取消
</NButton>
<NButton
type="primary"
attr-type="submit"
:loading="saving"
>
保存
</NButton>
</div>
</NForm>
<p
v-if="editError"
class="text-red-500 text-sm mt-2"
>
{{ editError }}
</p>
</NModal>
</AppShell>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NTag, NSpin } from 'naive-ui'
import {
NButton,
NTag,
NSpin,
NModal,
NForm,
NFormItem,
NInput,
NRadio,
NRadioGroup,
} from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useProjectsStore } from '@/stores/projects'
import { projectsApi, type Visibility } from '@/api/projects'
const store = useProjectsStore()
const route = useRoute()
@@ -81,6 +148,49 @@ const slug = route.params.slug as string
onMounted(() => store.load(slug))
// ---- Edit modal ----
const showEdit = ref(false)
const saving = ref(false)
const editError = ref('')
const editForm = ref<{ name: string; description: string; visibility: Visibility }>({
name: '',
description: '',
visibility: 'private',
})
function openEdit() {
if (!store.current) return
editForm.value = {
name: store.current.name,
description: store.current.description,
visibility: store.current.visibility,
}
editError.value = ''
showEdit.value = true
}
async function onEditSubmit() {
editError.value = ''
if (!editForm.value.name.trim()) {
editError.value = '名称不能为空'
return
}
saving.value = true
try {
await projectsApi.update(slug, {
name: editForm.value.name,
description: editForm.value.description,
visibility: editForm.value.visibility,
})
showEdit.value = false
await store.load(slug)
} catch (e) {
editError.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
function goRequirements() {
router.push({ name: 'requirement-list', params: { slug } })
}
@@ -4,6 +4,12 @@
<span>
日志 ·
<span :class="connClass">{{ connText }}</span>
<span
v-if="exitText"
class="text-gray-400"
>
· {{ exitText }}
</span>
</span>
<NButton
text
@@ -45,7 +51,7 @@ import { useRunStream } from '@/composables/useRunStream'
const props = defineProps<{ profileId: string }>()
const { status, logs, clear } = useRunStream(props.profileId)
const { status, runStatus, exitCode, logs, clear } = useRunStream(props.profileId)
const scrollRef = ref<InstanceType<typeof NScrollbar> | null>(null)
@@ -69,6 +75,12 @@ const connText = computed(() => {
return '已断开'
}
})
// 进程已停止/崩溃且带退出码时展示,例如「退出码 0」。
const exitText = computed(() =>
exitCode.value !== null && (runStatus.value === 'stopped' || runStatus.value === 'crashed')
? `退出码 ${exitCode.value}`
: '',
)
const connClass = computed(() =>
status.value === 'streaming'
? 'text-green-400'