You've already forked agentic-coding-workflow
Web优化 2
This commit is contained in:
+11
-12
@@ -146,10 +146,8 @@ const enc = (v: string) => encodeURIComponent(v)
|
||||
|
||||
export const acpApi = {
|
||||
agentKinds: {
|
||||
list: () =>
|
||||
request<(AgentKindAdmin | AgentKindPublic)[]>('/api/v1/acp/agent-kinds'),
|
||||
get: (id: string) =>
|
||||
request<AgentKindAdmin>(`/api/v1/acp/agent-kinds/${enc(id)}`),
|
||||
list: () => request<(AgentKindAdmin | AgentKindPublic)[]>('/api/v1/acp/agent-kinds'),
|
||||
get: (id: string) => request<AgentKindAdmin>(`/api/v1/acp/agent-kinds/${enc(id)}`),
|
||||
create: (req: CreateAgentKindReq) =>
|
||||
request<AgentKindAdmin>('/api/v1/acp/agent-kinds', { method: 'POST', body: req }),
|
||||
update: (id: string, req: UpdateAgentKindReq) =>
|
||||
@@ -180,8 +178,7 @@ export const acpApi = {
|
||||
const qs = q.toString()
|
||||
return request<AcpSession[]>('/api/v1/acp/sessions' + (qs ? '?' + qs : ''))
|
||||
},
|
||||
get: (id: string) =>
|
||||
request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
|
||||
get: (id: string) => request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
|
||||
create: (req: CreateSessionReq) =>
|
||||
request<AcpSession>('/api/v1/acp/sessions', { method: 'POST', body: req }),
|
||||
terminate: (id: string) =>
|
||||
@@ -205,13 +202,15 @@ export const acpApi = {
|
||||
// openSessionWS opens a native WebSocket to /sessions/{id}/ws.
|
||||
//
|
||||
// Auth: browser WebSocket cannot send custom headers, so the token is sent
|
||||
// via the ?token= query parameter. The backend auth middleware accepts it
|
||||
// as a fallback to Authorization. Keep token lifetimes short.
|
||||
// via the Sec-WebSocket-Protocol handshake as "acw-token.<token>". This is
|
||||
// safer than a URL query parameter (which may be logged by proxies). The
|
||||
// application subprotocol "acp-v1" is also advertised and selected by the
|
||||
// server when supported.
|
||||
export function openSessionWS(sessionId: string, since: number, token: string): WebSocket {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.host
|
||||
const url =
|
||||
`${proto}://${host}/api/v1/acp/sessions/${enc(sessionId)}` +
|
||||
`/ws?since=${since}&token=${enc(token)}`
|
||||
return new WebSocket(url)
|
||||
const url = `${proto}://${host}/api/v1/acp/sessions/${enc(sessionId)}/ws?since=${since}`
|
||||
const protocols = ['acp-v1']
|
||||
if (token) protocols.push(`acw-token.${token}`)
|
||||
return new WebSocket(url, protocols)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { NInput, NButton, NSelect, NSpin } from 'naive-ui'
|
||||
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
||||
import { acpApi, type AgentConfigFile, type AgentClientType } from '@/api/acp'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const props = defineProps<{
|
||||
kindId: string
|
||||
@@ -22,6 +23,7 @@ const props = defineProps<{
|
||||
const files = ref<AgentConfigFile[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ===== 客户端规范配置文件 =====
|
||||
|
||||
@@ -270,7 +272,8 @@ async function upsert(relPath: string, content: string) {
|
||||
}
|
||||
|
||||
async function removeFile(relPath: string) {
|
||||
if (!confirm(`删除配置文件 ${relPath}?`)) return
|
||||
const ok = await confirm(`删除配置文件 ${relPath}?`)
|
||||
if (!ok) return
|
||||
error.value = ''
|
||||
try {
|
||||
await acpApi.configFiles.remove(props.kindId, relPath)
|
||||
@@ -299,9 +302,7 @@ watch(() => props.clientType, loadForm)
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
客户端配置文件
|
||||
</h2>
|
||||
<h2 class="text-lg font-semibold">客户端配置文件</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
配置存数据库,session 启动前物化到受管目录并通过
|
||||
<code v-if="clientType === 'claude_code'">CLAUDE_CONFIG_DIR</code>
|
||||
@@ -312,22 +313,18 @@ watch(() => props.clientType, loadForm)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="text-sm text-red-600"
|
||||
>
|
||||
<p v-if="error" class="text-sm text-red-600">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<!-- 常用配置表单(已知客户端类型) -->
|
||||
<div
|
||||
v-if="canonicalPath"
|
||||
class="border rounded p-4 space-y-3"
|
||||
>
|
||||
<div v-if="canonicalPath" class="border rounded p-4 space-y-3">
|
||||
<div class="text-sm font-medium">
|
||||
常用配置
|
||||
<span class="text-xs text-gray-500 font-normal">(写入 {{ canonicalPath }},其余字段保留)</span>
|
||||
<span class="text-xs text-gray-500 font-normal"
|
||||
>(写入 {{ canonicalPath }},其余字段保留)</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Model</label>
|
||||
@@ -345,10 +342,7 @@ watch(() => props.clientType, loadForm)
|
||||
<template v-if="clientType === 'claude_code'">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">permissions.defaultMode</label>
|
||||
<NInput
|
||||
v-model:value="form.defaultMode"
|
||||
placeholder="acceptEdits"
|
||||
/>
|
||||
<NInput v-model:value="form.defaultMode" placeholder="acceptEdits" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
@@ -392,34 +386,17 @@ watch(() => props.clientType, loadForm)
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">sandbox_mode</label>
|
||||
<NSelect
|
||||
v-model:value="form.sandboxMode"
|
||||
:options="sandboxModeOptions"
|
||||
clearable
|
||||
/>
|
||||
<NSelect v-model:value="form.sandboxMode" :options="sandboxModeOptions" clearable />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="saveForm"
|
||||
>
|
||||
保存常用配置
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="saveForm"> 保存常用配置 </NButton>
|
||||
</div>
|
||||
|
||||
<!-- 高级模式:文件列表 + 原始编辑 -->
|
||||
<div class="border rounded p-4 space-y-3 mt-4">
|
||||
<div class="text-sm font-medium">
|
||||
全部配置文件(高级模式)
|
||||
</div>
|
||||
<div
|
||||
v-if="files.length === 0"
|
||||
class="text-sm text-gray-500"
|
||||
>
|
||||
暂无配置文件
|
||||
</div>
|
||||
<div class="text-sm font-medium">全部配置文件(高级模式)</div>
|
||||
<div v-if="files.length === 0" class="text-sm text-gray-500">暂无配置文件</div>
|
||||
<div
|
||||
v-for="f in files"
|
||||
:key="f.rel_path"
|
||||
@@ -428,31 +405,15 @@ watch(() => props.clientType, loadForm)
|
||||
<code>{{ f.rel_path }}</code>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-xs text-gray-400">{{ f.updated_at }}</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
@click="startEdit(f)"
|
||||
>
|
||||
编辑
|
||||
</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
@click="removeFile(f.rel_path)"
|
||||
>
|
||||
<NButton size="tiny" @click="startEdit(f)"> 编辑 </NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click="removeFile(f.rel_path)">
|
||||
删除
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<NButton
|
||||
size="small"
|
||||
dashed
|
||||
@click="startNew()"
|
||||
>
|
||||
+ 新建文件
|
||||
</NButton>
|
||||
<NButton size="small" dashed @click="startNew()"> + 新建文件 </NButton>
|
||||
<NButton
|
||||
v-for="t in templates.filter((x) => !files.some((f) => f.rel_path === x.rel_path))"
|
||||
:key="t.rel_path"
|
||||
@@ -464,16 +425,12 @@ watch(() => props.clientType, loadForm)
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="editingIsNew || editingPath !== null"
|
||||
class="space-y-2 pt-2"
|
||||
>
|
||||
<div v-if="editingIsNew || editingPath !== null" class="space-y-2 pt-2">
|
||||
<div v-if="editingIsNew">
|
||||
<label class="block text-sm mb-1">文件路径(相对受管 home,如 .claude/settings.json)</label>
|
||||
<NInput
|
||||
v-model:value="newRelPath"
|
||||
placeholder=".claude/settings.json"
|
||||
/>
|
||||
<label class="block text-sm mb-1"
|
||||
>文件路径(相对受管 home,如 .claude/settings.json)</label
|
||||
>
|
||||
<NInput v-model:value="newRelPath" placeholder=".claude/settings.json" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<code class="text-sm">{{ editingPath }}</code>
|
||||
@@ -484,27 +441,12 @@ watch(() => props.clientType, loadForm)
|
||||
class="font-mono"
|
||||
:autosize="{ minRows: 8, maxRows: 24 }"
|
||||
/>
|
||||
<p
|
||||
v-if="syntaxError"
|
||||
class="text-xs text-red-600"
|
||||
>
|
||||
语法错误:{{ syntaxError }}
|
||||
</p>
|
||||
<p v-if="syntaxError" class="text-xs text-red-600">语法错误:{{ syntaxError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!!syntaxError"
|
||||
@click="saveEdit"
|
||||
>
|
||||
<NButton size="small" type="primary" :disabled="!!syntaxError" @click="saveEdit">
|
||||
保存
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
取消
|
||||
</NButton>
|
||||
<NButton size="small" @click="cancelEdit"> 取消 </NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ class MockWebSocket {
|
||||
}
|
||||
|
||||
url: string
|
||||
protocols: string[] | undefined
|
||||
readyState = 0
|
||||
onopen: ((ev: unknown) => void) | null = null
|
||||
onmessage: ((ev: { data: string }) => void) | null = null
|
||||
@@ -24,8 +25,9 @@ class MockWebSocket {
|
||||
onclose: ((ev: unknown) => void) | null = null
|
||||
sent: string[] = []
|
||||
|
||||
constructor(url: string) {
|
||||
constructor(url: string, protocols?: string[]) {
|
||||
this.url = url
|
||||
this.protocols = protocols
|
||||
MockWebSocket.instances.push(this)
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1
|
||||
@@ -66,7 +68,8 @@ describe('useAcpStream', () => {
|
||||
const ws = MockWebSocket.last()
|
||||
expect(ws.url).toContain('/sessions/sess-1/ws')
|
||||
expect(ws.url).toContain('since=0')
|
||||
expect(ws.url).toContain('token=jwt-token')
|
||||
expect(ws.url).not.toContain('token=')
|
||||
expect(ws.protocols).toEqual(['acp-v1', 'acw-token.jwt-token'])
|
||||
expect(stream.status.value).toBe('streaming')
|
||||
stream.close()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
export function useAlert() {
|
||||
let message: ReturnType<typeof useMessage> | undefined
|
||||
try {
|
||||
message = useMessage()
|
||||
} catch {
|
||||
// No outer <n-message-provider />, fall back to window.alert in browser.
|
||||
}
|
||||
|
||||
return (content: string) => {
|
||||
if (message) {
|
||||
message.error(content)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
window.alert(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useDialog } from 'naive-ui'
|
||||
|
||||
export function useConfirm() {
|
||||
let dialog: ReturnType<typeof useDialog> | undefined
|
||||
try {
|
||||
dialog = useDialog()
|
||||
} catch {
|
||||
// No outer <n-dialog-provider />, fall back to window.confirm in browser.
|
||||
}
|
||||
|
||||
return (content: string, title = '确认') => {
|
||||
if (!dialog) {
|
||||
if (typeof window !== 'undefined') {
|
||||
return Promise.resolve(window.confirm(content))
|
||||
}
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
dialog!.warning({
|
||||
title,
|
||||
content,
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => resolve(true),
|
||||
onNegativeClick: () => resolve(false),
|
||||
onClose: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import type {
|
||||
CommitBody,
|
||||
} from '@/api/types'
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1500
|
||||
const DEFAULT_POLL_MAX_MS = 5 * 60 * 1000
|
||||
|
||||
export const useWorkspacesStore = defineStore('workspaces', () => {
|
||||
const list = ref<Workspace[]>([])
|
||||
const current = ref<Workspace | null>(null)
|
||||
@@ -75,8 +78,8 @@ export const useWorkspacesStore = defineStore('workspaces', () => {
|
||||
*/
|
||||
async function pollUntilSettled(
|
||||
wsID: string,
|
||||
intervalMs = 1500,
|
||||
maxMs = 5 * 60 * 1000,
|
||||
intervalMs = DEFAULT_POLL_INTERVAL_MS,
|
||||
maxMs = DEFAULT_POLL_MAX_MS,
|
||||
): Promise<Workspace> {
|
||||
const start = Date.now()
|
||||
let lastStatus: Workspace['sync_status'] | 'unknown' = 'unknown'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
+22
-43
@@ -2,44 +2,25 @@
|
||||
<AppShell>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">
|
||||
欢迎,{{ auth.user?.display_name }}
|
||||
</h2>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="router.push({ name: 'project-list' })"
|
||||
>
|
||||
新建 / 管理项目
|
||||
</NButton>
|
||||
<h2 class="text-xl font-semibold">欢迎,{{ auth.user?.display_name }}</h2>
|
||||
<NButton type="primary" @click="goToProjects"> 新建 / 管理项目 </NButton>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<NGrid
|
||||
:cols="3"
|
||||
:x-gap="12"
|
||||
>
|
||||
<NGrid :cols="3" :x-gap="12">
|
||||
<NGi>
|
||||
<NCard>
|
||||
<NStatistic
|
||||
label="活跃项目"
|
||||
:value="activeCount"
|
||||
/>
|
||||
<NStatistic label="活跃项目" :value="activeCount" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NCard>
|
||||
<NStatistic
|
||||
label="归档项目"
|
||||
:value="archivedCount"
|
||||
/>
|
||||
<NStatistic label="归档项目" :value="archivedCount" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NCard>
|
||||
<NStatistic
|
||||
label="分配给我的待办议题"
|
||||
:value="myIssues.length"
|
||||
/>
|
||||
<NStatistic label="分配给我的待办议题" :value="myIssues.length" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
@@ -47,16 +28,10 @@
|
||||
<!-- 分配给我的议题 -->
|
||||
<NCard title="分配给我的议题(未关闭)">
|
||||
<NSpin :show="loading">
|
||||
<div
|
||||
v-if="myIssues.length === 0"
|
||||
class="text-sm text-gray-400 py-2"
|
||||
>
|
||||
<div v-if="myIssues.length === 0" class="text-sm text-gray-400 py-2">
|
||||
暂无分配给你的未关闭议题。
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
class="divide-y"
|
||||
>
|
||||
<ul v-else class="divide-y">
|
||||
<li
|
||||
v-for="row in myIssues"
|
||||
:key="row.issue.id"
|
||||
@@ -64,7 +39,9 @@
|
||||
@click="goIssue(row)"
|
||||
>
|
||||
<span class="text-sm">
|
||||
<span class="font-mono text-gray-500 mr-2">{{ row.projectSlug }}#{{ row.issue.number }}</span>
|
||||
<span class="font-mono text-gray-500 mr-2"
|
||||
>{{ row.projectSlug }}#{{ row.issue.number }}</span
|
||||
>
|
||||
{{ row.issue.title }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ row.projectName }}</span>
|
||||
@@ -76,21 +53,15 @@
|
||||
<!-- 最近项目 -->
|
||||
<NCard title="项目">
|
||||
<NSpin :show="loading">
|
||||
<div
|
||||
v-if="activeProjects.length === 0"
|
||||
class="text-sm text-gray-400 py-2"
|
||||
>
|
||||
<div v-if="activeProjects.length === 0" class="text-sm text-gray-400 py-2">
|
||||
还没有项目,去创建一个吧。
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
class="divide-y"
|
||||
>
|
||||
<ul v-else class="divide-y">
|
||||
<li
|
||||
v-for="p in activeProjects"
|
||||
:key="p.id"
|
||||
class="py-2 flex items-center justify-between cursor-pointer hover:bg-gray-50 px-1"
|
||||
@click="router.push({ name: 'project-home', params: { slug: p.slug } })"
|
||||
@click="goToProject(p)"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ p.name }}</span>
|
||||
<span class="font-mono text-xs text-gray-400">{{ p.slug }}</span>
|
||||
@@ -163,4 +134,12 @@ function goIssue(row: MyIssueRow) {
|
||||
params: { slug: row.projectSlug, number: row.issue.number },
|
||||
})
|
||||
}
|
||||
|
||||
function goToProjects() {
|
||||
router.push({ name: 'project-list' })
|
||||
}
|
||||
|
||||
function goToProject(p: Project) {
|
||||
router.push({ name: 'project-home', params: { slug: p.slug } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
|
||||
const store = useAcpStore()
|
||||
const confirm = useConfirm()
|
||||
const alert = useAlert()
|
||||
|
||||
onMounted(() => {
|
||||
// all=true → 管理员视图,列出所有用户的会话。
|
||||
@@ -12,7 +18,8 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function terminate(id: string) {
|
||||
if (!confirm('Terminate this session?')) return
|
||||
const ok = await confirm('Terminate this session?')
|
||||
if (!ok) return
|
||||
try {
|
||||
await store.terminateSession(id)
|
||||
} catch (e) {
|
||||
@@ -25,60 +32,28 @@ async function terminate(id: string) {
|
||||
<AppShell>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">
|
||||
ACP 会话管理
|
||||
</h2>
|
||||
<h2 class="text-lg font-semibold">ACP 会话管理</h2>
|
||||
<span class="text-sm text-gray-500">全局管理员视图(所有用户)</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.loading"
|
||||
class="text-gray-500"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="store.error"
|
||||
class="text-red-600"
|
||||
>
|
||||
<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>
|
||||
<div v-else-if="store.sessions.length === 0" class="text-gray-500">No sessions.</div>
|
||||
|
||||
<table
|
||||
v-else
|
||||
class="w-full text-sm"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
@@ -89,16 +64,17 @@ async function terminate(id: string) {
|
||||
<SessionStatusBadge :status="s.status" />
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">
|
||||
{{ new Date(s.started_at).toLocaleString() }}
|
||||
{{ formatDateTime(s.started_at) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right space-x-2">
|
||||
<button
|
||||
<NButton
|
||||
v-if="s.status === 'starting' || s.status === 'running'"
|
||||
class="text-red-600 hover:underline"
|
||||
text
|
||||
type="error"
|
||||
@click="terminate(s.id)"
|
||||
>
|
||||
Terminate
|
||||
</button>
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useAcpStream, type UseAcpStreamReturn } from '@/composables/useAcpStream'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
@@ -15,6 +17,7 @@ import CommitDialog from '@/views/workspaces/components/CommitDialog.vue'
|
||||
const route = useRoute()
|
||||
const store = useAcpStore()
|
||||
const auth = useAuthStore()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const sessionId = computed(() => route.params.sessionId as string)
|
||||
|
||||
@@ -119,7 +122,8 @@ function onCancel() {
|
||||
}
|
||||
|
||||
async function terminate() {
|
||||
if (!confirm('Terminate this session?')) return
|
||||
const ok = await confirm('Terminate this session?')
|
||||
if (!ok) return
|
||||
try {
|
||||
await store.terminateSession(sessionId.value)
|
||||
} catch (e) {
|
||||
@@ -166,42 +170,31 @@ async function openCommit() {
|
||||
<div class="space-x-3 text-sm">
|
||||
<span class="font-mono">{{ store.currentSession?.branch }}</span>
|
||||
<span class="text-gray-500">·</span>
|
||||
<span
|
||||
class="text-xs text-gray-500 font-mono truncate max-w-md inline-block align-middle"
|
||||
>
|
||||
<span class="text-xs text-gray-500 font-mono truncate max-w-md inline-block align-middle">
|
||||
{{ store.currentSession?.cwd_path }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionStatusBadge :status="sessionStatus" />
|
||||
<button
|
||||
class="px-2 py-1 text-xs rounded border text-blue-600 hover:bg-blue-50"
|
||||
@click="openCommit"
|
||||
>
|
||||
提交代码
|
||||
</button>
|
||||
<button
|
||||
<NButton text type="primary" size="small" @click="openCommit"> 提交代码 </NButton>
|
||||
<NButton
|
||||
v-if="sessionStatus === 'starting' || sessionStatus === 'running'"
|
||||
class="px-2 py-1 text-xs rounded border text-red-600 hover:bg-red-50"
|
||||
text
|
||||
type="error"
|
||||
size="small"
|
||||
@click="terminate"
|
||||
>
|
||||
Terminate
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="errorMsg"
|
||||
class="px-4 py-2 text-sm text-red-600 bg-red-50"
|
||||
>
|
||||
<p v-if="errorMsg" class="px-4 py-2 text-sm text-red-600 bg-red-50">
|
||||
{{ errorMsg }}
|
||||
</p>
|
||||
|
||||
<!-- WS client_error 帧提示 -->
|
||||
<p
|
||||
v-if="streamError"
|
||||
class="px-4 py-2 text-sm text-red-700 bg-red-100"
|
||||
>
|
||||
<p v-if="streamError" class="px-4 py-2 text-sm text-red-700 bg-red-100">
|
||||
连接错误:{{ streamError }}
|
||||
</p>
|
||||
|
||||
@@ -214,19 +207,9 @@ async function openCommit() {
|
||||
/>
|
||||
|
||||
<!-- Events stream -->
|
||||
<div
|
||||
ref="eventsContainer"
|
||||
class="flex-1 overflow-y-auto px-4 py-3 space-y-1"
|
||||
>
|
||||
<AgentEventCard
|
||||
v-for="ev in events"
|
||||
:key="ev.id"
|
||||
:event="ev"
|
||||
/>
|
||||
<div
|
||||
v-if="events.length === 0"
|
||||
class="text-center text-gray-400 py-8"
|
||||
>
|
||||
<div ref="eventsContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-1">
|
||||
<AgentEventCard v-for="ev in events" :key="ev.id" :event="ev" />
|
||||
<div v-if="events.length === 0" class="text-center text-gray-400 py-8">
|
||||
Waiting for agent...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAcpStore()
|
||||
const confirm = useConfirm()
|
||||
const alert = useAlert()
|
||||
|
||||
onMounted(() => {
|
||||
void store.listSessions(false)
|
||||
})
|
||||
|
||||
function open(id: string) {
|
||||
router.push(
|
||||
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/${id}`,
|
||||
)
|
||||
router.push(`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/${id}`)
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
router.push(
|
||||
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/new`,
|
||||
)
|
||||
router.push(`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/new`)
|
||||
}
|
||||
|
||||
async function terminate(id: string) {
|
||||
if (!confirm('Terminate this session?')) return
|
||||
const ok = await confirm('Terminate this session?')
|
||||
if (!ok) return
|
||||
try {
|
||||
await store.terminateSession(id)
|
||||
} catch (e) {
|
||||
@@ -37,62 +40,29 @@ async function terminate(id: string) {
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">
|
||||
ACP Sessions
|
||||
</h1>
|
||||
<button
|
||||
class="px-3 py-1 rounded text-sm font-medium bg-blue-600 text-white hover:bg-blue-700"
|
||||
@click="newSession"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
<h1 class="text-2xl font-semibold">ACP Sessions</h1>
|
||||
<NButton type="primary" @click="newSession"> + New </NButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.loading"
|
||||
class="text-gray-500"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="store.error"
|
||||
class="text-red-600"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div v-else-if="store.sessions.length === 0" class="text-gray-500">
|
||||
No sessions yet. Click "+ New" to create one.
|
||||
</div>
|
||||
|
||||
<table
|
||||
v-else
|
||||
class="w-full text-sm"
|
||||
>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="text-left">
|
||||
<tr class="border-b">
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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.branch }}
|
||||
</td>
|
||||
@@ -100,22 +70,18 @@ async function terminate(id: string) {
|
||||
<SessionStatusBadge :status="s.status" />
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">
|
||||
{{ new Date(s.started_at).toLocaleString() }}
|
||||
{{ formatDateTime(s.started_at) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right space-x-2">
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="open(s.id)"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
<NButton text type="primary" @click="open(s.id)"> Open </NButton>
|
||||
<NButton
|
||||
v-if="s.status === 'starting' || s.status === 'running'"
|
||||
class="text-red-600 hover:underline"
|
||||
text
|
||||
type="error"
|
||||
@click="terminate(s.id)"
|
||||
>
|
||||
Terminate
|
||||
</button>
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useRouter } from 'vue-router'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAcpStore()
|
||||
const confirm = useConfirm()
|
||||
const alert = useAlert()
|
||||
|
||||
onMounted(() => store.listAgentKinds(true))
|
||||
|
||||
@@ -14,8 +18,17 @@ async function toggleEnabled(id: string, enabled: boolean) {
|
||||
await store.updateAgentKind(id, { enabled })
|
||||
}
|
||||
|
||||
function goNew() {
|
||||
router.push('/admin/acp/agent-kinds/new')
|
||||
}
|
||||
|
||||
function goEdit(id: string) {
|
||||
router.push(`/admin/acp/agent-kinds/${id}`)
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Delete agent kind "${name}"?`)) return
|
||||
const ok = await confirm(`Delete agent kind "${name}"?`)
|
||||
if (!ok) return
|
||||
try {
|
||||
await store.deleteAgentKind(id)
|
||||
} catch (e) {
|
||||
@@ -29,51 +42,24 @@ async function remove(id: string, name: string) {
|
||||
<NSpin :show="store.loading">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">
|
||||
ACP Agent Kinds
|
||||
</h1>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="router.push('/admin/acp/agent-kinds/new')"
|
||||
>
|
||||
+ New
|
||||
</NButton>
|
||||
<h1 class="text-2xl font-semibold">ACP Agent Kinds</h1>
|
||||
<NButton type="primary" @click="goNew"> + New </NButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.error"
|
||||
class="text-red-600"
|
||||
>
|
||||
<div v-if="store.error" class="text-red-600">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
<table
|
||||
v-else
|
||||
class="w-full text-sm"
|
||||
>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="text-left">
|
||||
<tr>
|
||||
<th class="px-3 py-2">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Display
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Binary
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Enabled
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right">
|
||||
Actions
|
||||
</th>
|
||||
<th class="px-3 py-2">Name</th>
|
||||
<th class="px-3 py-2">Display</th>
|
||||
<th class="px-3 py-2">Binary</th>
|
||||
<th class="px-3 py-2">Enabled</th>
|
||||
<th class="px-3 py-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="k in store.agentKinds"
|
||||
:key="k.id"
|
||||
class="border-t"
|
||||
>
|
||||
<tr v-for="k in store.agentKinds" :key="k.id" class="border-t">
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{{ k.name }}
|
||||
</td>
|
||||
@@ -88,23 +74,11 @@ async function remove(id: string, name: string) {
|
||||
type="checkbox"
|
||||
:checked="k.enabled"
|
||||
@change="toggleEnabled(k.id, !k.enabled)"
|
||||
>
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right space-x-2">
|
||||
<NButton
|
||||
text
|
||||
type="primary"
|
||||
@click="router.push(`/admin/acp/agent-kinds/${k.id}`)"
|
||||
>
|
||||
Edit
|
||||
</NButton>
|
||||
<NButton
|
||||
text
|
||||
type="error"
|
||||
@click="remove(k.id, k.name)"
|
||||
>
|
||||
Delete
|
||||
</NButton>
|
||||
<NButton text type="primary" @click="goEdit(k.id)"> Edit </NButton>
|
||||
<NButton text type="error" @click="remove(k.id, k.name)"> Delete </NButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -5,11 +5,17 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import AgentKindForm from '@/components/acp/AgentKindForm.vue'
|
||||
import AgentConfigFilesPanel from '@/components/acp/AgentConfigFilesPanel.vue'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import type { AgentClientType, McpServerSpec, UpdateAgentKindReq } from '@/api/acp'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAcpStore()
|
||||
const alert = useAlert()
|
||||
|
||||
function goBack() {
|
||||
router.push('/admin/acp/agent-kinds')
|
||||
}
|
||||
|
||||
const id = computed(() => (route.params.id as string | undefined) ?? null)
|
||||
const isEdit = computed(() => id.value !== null)
|
||||
@@ -79,7 +85,7 @@ async function submit() {
|
||||
mcp_servers: form.value.mcp_servers,
|
||||
})
|
||||
}
|
||||
router.push('/admin/acp/agent-kinds')
|
||||
goBack()
|
||||
} catch (e) {
|
||||
alert(`Save failed: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
}
|
||||
@@ -97,21 +103,12 @@ async function submit() {
|
||||
:is-edit="isEdit"
|
||||
:existing-env-keys="existingEnvKeys"
|
||||
@submit="submit"
|
||||
@cancel="router.push('/admin/acp/agent-kinds')"
|
||||
@cancel="goBack"
|
||||
/>
|
||||
<div
|
||||
v-if="isEdit && id"
|
||||
class="mt-8"
|
||||
>
|
||||
<AgentConfigFilesPanel
|
||||
:kind-id="id"
|
||||
:client-type="form.client_type"
|
||||
/>
|
||||
<div v-if="isEdit && id" class="mt-8">
|
||||
<AgentConfigFilesPanel :kind-id="id" :client-type="form.client_type" />
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-gray-500 mt-6"
|
||||
>
|
||||
<p v-else class="text-xs text-gray-500 mt-6">
|
||||
保存后可在编辑页配置该客户端的配置文件(settings.json / config.toml 等)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
{{ adminMode ? '用量(全局)' : '我的用量' }}
|
||||
</h2>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<NDatePicker
|
||||
v-model:value="range"
|
||||
type="daterange"
|
||||
clearable
|
||||
/>
|
||||
<NDatePicker v-model:value="range" type="daterange" clearable />
|
||||
<NSelect
|
||||
v-if="adminMode"
|
||||
v-model:value="groupBy"
|
||||
@@ -17,10 +13,7 @@
|
||||
style="width: 160px"
|
||||
/>
|
||||
</div>
|
||||
<NDataTable
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
/>
|
||||
<NDataTable :data="rows" :columns="columns" />
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -31,12 +24,9 @@ import { NDataTable, NDatePicker, NSelect } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import {
|
||||
llmAdminApi,
|
||||
type AdminUsageGroupBy,
|
||||
type AdminUsageItem,
|
||||
} from '@/api/llmAdmin'
|
||||
import { llmAdminApi, type AdminUsageGroupBy, type AdminUsageItem } from '@/api/llmAdmin'
|
||||
import { chatApi, type UsageDailyItem } from '@/api/chat'
|
||||
import { formatDate } from '@/utils/date'
|
||||
|
||||
interface Props {
|
||||
adminMode?: boolean
|
||||
@@ -69,17 +59,12 @@ const columns = computed<DataTableColumns<Row>>(() => {
|
||||
]
|
||||
if (props.adminMode) {
|
||||
const labelCol: DataTableColumns<Row>[number] = {
|
||||
title:
|
||||
groupBy.value === 'user'
|
||||
? '用户'
|
||||
: groupBy.value === 'model'
|
||||
? '模型'
|
||||
: '日期',
|
||||
title: groupBy.value === 'user' ? '用户' : groupBy.value === 'model' ? '模型' : '日期',
|
||||
key: 'key',
|
||||
render: (r) => {
|
||||
const k = (r as AdminUsageItem).key
|
||||
if (groupBy.value === 'day') {
|
||||
return new Date(k).toLocaleDateString('zh-CN')
|
||||
return formatDate(k)
|
||||
}
|
||||
return k
|
||||
},
|
||||
@@ -90,7 +75,7 @@ const columns = computed<DataTableColumns<Row>>(() => {
|
||||
{
|
||||
title: '日期',
|
||||
key: 'day',
|
||||
render: (r) => new Date((r as UsageDailyItem).day).toLocaleDateString('zh-CN'),
|
||||
render: (r) => formatDate((r as UsageDailyItem).day),
|
||||
},
|
||||
...tokenCols,
|
||||
]
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
<div class="flex h-[calc(100vh-8rem)] -m-6">
|
||||
<!-- 左侧会话列表 -->
|
||||
<div class="w-72 border-r overflow-y-auto p-2 flex flex-col gap-1">
|
||||
<NButton
|
||||
block
|
||||
class="mb-2"
|
||||
@click="showCreate = true"
|
||||
>
|
||||
+ 新建对话
|
||||
</NButton>
|
||||
<NButton block class="mb-2" @click="showCreate = true"> + 新建对话 </NButton>
|
||||
<div
|
||||
v-for="c in chat.conversations"
|
||||
:key="c.id"
|
||||
@@ -27,10 +21,7 @@
|
||||
{{ formatTime(c.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<NPopconfirm
|
||||
:show-icon="false"
|
||||
@positive-click="chat.deleteConversation(c.id)"
|
||||
>
|
||||
<NPopconfirm :show-icon="false" @positive-click="chat.deleteConversation(c.id)">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
text
|
||||
@@ -43,15 +34,10 @@
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
<template #default>
|
||||
删除后无法恢复,确定删除此会话?
|
||||
</template>
|
||||
<template #default> 删除后无法恢复,确定删除此会话? </template>
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<div
|
||||
v-if="chat.conversations.length === 0"
|
||||
class="text-xs text-neutral-400 px-2"
|
||||
>
|
||||
<div v-if="chat.conversations.length === 0" class="text-xs text-neutral-400 px-2">
|
||||
暂无对话
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,30 +56,19 @@
|
||||
<div class="text-sm font-medium truncate flex-1">
|
||||
{{ chat.currentConversation.title || '(未命名)' }}
|
||||
</div>
|
||||
<NPopconfirm
|
||||
:show-icon="false"
|
||||
@positive-click="deleteCurrentConversation"
|
||||
>
|
||||
<NPopconfirm :show-icon="false" @positive-click="deleteCurrentConversation">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
text
|
||||
size="small"
|
||||
>
|
||||
<NButton text size="small">
|
||||
<template #icon>
|
||||
<NIcon><TrashOutline /></NIcon>
|
||||
</template>
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
<template #default>
|
||||
删除后无法恢复,确定删除此会话?
|
||||
</template>
|
||||
<template #default> 删除后无法恢复,确定删除此会话? </template>
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<div
|
||||
ref="scrollEl"
|
||||
class="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
>
|
||||
<div ref="scrollEl" class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<MessageBubble
|
||||
v-for="m in chat.messages"
|
||||
:key="m.id"
|
||||
@@ -125,13 +100,7 @@
|
||||
>
|
||||
发送
|
||||
</NButton>
|
||||
<NButton
|
||||
v-else
|
||||
type="warning"
|
||||
@click="chat.cancelMessage"
|
||||
>
|
||||
中断
|
||||
</NButton>
|
||||
<NButton v-else type="warning" @click="chat.cancelMessage"> 中断 </NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -154,6 +123,7 @@ import { TrashOutline } from '@vicons/ionicons5'
|
||||
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
const MessageBubble = defineAsyncComponent(() => import('./components/MessageBubble.vue'))
|
||||
import AttachmentUploader from './components/AttachmentUploader.vue'
|
||||
const ConversationCreateModal = defineAsyncComponent(() => import('./ConversationCreateModal.vue'))
|
||||
@@ -271,6 +241,6 @@ async function deleteCurrentConversation() {
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
return formatDateTime(iso)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<template>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<NUpload
|
||||
:custom-request="upload"
|
||||
:show-file-list="false"
|
||||
multiple
|
||||
>
|
||||
<NButton size="small">
|
||||
添加附件
|
||||
</NButton>
|
||||
<NUpload :custom-request="upload" :show-file-list="false" multiple>
|
||||
<NButton size="small"> 添加附件 </NButton>
|
||||
</NUpload>
|
||||
<div
|
||||
v-for="a in attachments"
|
||||
@@ -15,13 +9,7 @@
|
||||
class="text-xs px-2 py-1 bg-neutral-100 rounded flex items-center gap-1"
|
||||
>
|
||||
<span>{{ a.filename }} ({{ Math.round(a.size_bytes / 1024) }} KB)</span>
|
||||
<NButton
|
||||
text
|
||||
size="tiny"
|
||||
@click="remove(a.id)"
|
||||
>
|
||||
×
|
||||
</NButton>
|
||||
<NButton text size="tiny" @click="remove(a.id)"> × </NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,6 +29,9 @@ const emit = defineEmits<{ change: [ids: string[]] }>()
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const msg = useMessage()
|
||||
|
||||
const MAX_ATTACHMENT_SIZE_MB = 20
|
||||
const MAX_ATTACHMENT_SIZE_BYTES = MAX_ATTACHMENT_SIZE_MB * 1024 * 1024
|
||||
|
||||
async function upload(opts: UploadCustomRequestOptions) {
|
||||
const f = opts.file.file
|
||||
if (!f) {
|
||||
@@ -62,8 +53,8 @@ async function upload(opts: UploadCustomRequestOptions) {
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
if (f.size > 20 * 1024 * 1024) {
|
||||
msg.error('单文件不能超过 20 MB')
|
||||
if (f.size > MAX_ATTACHMENT_SIZE_BYTES) {
|
||||
msg.error(`单文件不能超过 ${MAX_ATTACHMENT_SIZE_MB} MB`)
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
@@ -83,7 +74,11 @@ function remove(id: string) {
|
||||
|
||||
watch(
|
||||
() => attachments.value.map((a) => a.id),
|
||||
() => emit('change', attachments.value.map((a) => a.id)),
|
||||
() =>
|
||||
emit(
|
||||
'change',
|
||||
attachments.value.map((a) => a.id),
|
||||
),
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<AppShell>
|
||||
<NSpin :show="loading">
|
||||
<div
|
||||
v-if="data"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div v-if="data" class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -12,10 +9,7 @@
|
||||
<h1 class="text-2xl font-semibold">
|
||||
{{ data.requirement.title }}
|
||||
</h1>
|
||||
<NTag
|
||||
:type="data.requirement.status === 'open' ? 'success' : 'default'"
|
||||
size="small"
|
||||
>
|
||||
<NTag :type="data.requirement.status === 'open' ? 'success' : 'default'" size="small">
|
||||
{{ data.requirement.status === 'open' ? '开放' : '关闭' }}
|
||||
</NTag>
|
||||
</div>
|
||||
@@ -29,30 +23,16 @@
|
||||
>
|
||||
关闭需求
|
||||
</NButton>
|
||||
<NButton
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="toggling"
|
||||
@click="onReopen"
|
||||
>
|
||||
<NButton v-else type="primary" size="small" :loading="toggling" @click="onReopen">
|
||||
重新开放
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
@click="router.back()"
|
||||
>
|
||||
返回
|
||||
</NButton>
|
||||
<NButton size="small" @click="router.back()"> 返回 </NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase flow bar(阶段跳转始终由用户确认触发) -->
|
||||
<div class="flex items-center gap-1">
|
||||
<template
|
||||
v-for="(phase, idx) in PHASES"
|
||||
:key="phase"
|
||||
>
|
||||
<template v-for="(phase, idx) in PHASES" :key="phase">
|
||||
<NButton
|
||||
:type="phase === data.requirement.phase ? 'primary' : 'default'"
|
||||
:secondary="phase !== data.requirement.phase"
|
||||
@@ -62,10 +42,7 @@
|
||||
>
|
||||
{{ PHASE_LABELS[phase] }}
|
||||
</NButton>
|
||||
<span
|
||||
v-if="idx < PHASES.length - 1"
|
||||
class="text-gray-300 text-xs"
|
||||
>›</span>
|
||||
<span v-if="idx < PHASES.length - 1" class="text-gray-300 text-xs">›</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -74,18 +51,10 @@
|
||||
<div class="w-[45%] shrink-0 space-y-6">
|
||||
<!-- Description -->
|
||||
<NCard title="描述">
|
||||
<p
|
||||
v-if="data.requirement.description"
|
||||
class="text-gray-700 whitespace-pre-wrap"
|
||||
>
|
||||
<p v-if="data.requirement.description" class="text-gray-700 whitespace-pre-wrap">
|
||||
{{ data.requirement.description }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-gray-400 text-sm"
|
||||
>
|
||||
暂无描述
|
||||
</p>
|
||||
<p v-else class="text-gray-400 text-sm">暂无描述</p>
|
||||
</NCard>
|
||||
|
||||
<!-- 当前阶段产物(仅 AI 对话三阶段显示,保留编辑功能) -->
|
||||
@@ -122,34 +91,22 @@
|
||||
|
||||
<!-- Linked Issues -->
|
||||
<NCard title="关联 Issue">
|
||||
<NList
|
||||
v-if="data.issues.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="issue in data.issues"
|
||||
:key="issue.id"
|
||||
>
|
||||
<NList v-if="data.issues.length > 0" :show-divider="false">
|
||||
<NListItem v-for="issue in data.issues" :key="issue.id">
|
||||
<NThing>
|
||||
<template #header>
|
||||
<span class="font-mono text-xs text-gray-400 mr-2">#{{ issue.number }}</span>
|
||||
<span class="text-sm font-medium">{{ issue.title }}</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NTag
|
||||
:type="issue.status === 'open' ? 'success' : 'default'"
|
||||
size="tiny"
|
||||
>
|
||||
<NTag :type="issue.status === 'open' ? 'success' : 'default'" size="tiny">
|
||||
{{ issue.status === 'open' ? '开放' : '关闭' }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无关联 Issue"
|
||||
/>
|
||||
<NEmpty v-else description="暂无关联 Issue" />
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
@@ -172,19 +129,12 @@
|
||||
:slug="slug"
|
||||
:requirement="data.requirement"
|
||||
/>
|
||||
<RequirementSummaryPanel
|
||||
v-else
|
||||
:slug="slug"
|
||||
:number="number"
|
||||
/>
|
||||
<RequirementSummaryPanel v-else :slug="slug" :number="number" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="text-gray-400 text-sm py-8 text-center"
|
||||
>
|
||||
<div v-else-if="!loading" class="text-gray-400 text-sm py-8 text-center">
|
||||
需求不存在或无权访问。
|
||||
</div>
|
||||
</NSpin>
|
||||
@@ -195,17 +145,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDialog } from 'naive-ui'
|
||||
import {
|
||||
NCard,
|
||||
NButton,
|
||||
NTag,
|
||||
NSpin,
|
||||
NSelect,
|
||||
NList,
|
||||
NListItem,
|
||||
NThing,
|
||||
NEmpty,
|
||||
} from 'naive-ui'
|
||||
import { NCard, NButton, NTag, NSpin, NSelect, NList, NListItem, NThing, NEmpty } from 'naive-ui'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { projectsApi } from '@/api/projects'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
@@ -295,8 +235,7 @@ function onArtifactSaved() {
|
||||
async function onWorkspaceChange(v: string | null) {
|
||||
if (!data.value) return
|
||||
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
|
||||
// Re-fetch to keep data.requirement and data.issues consistent (mirrors onPhaseClick)
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
data.value.requirement.workspace_id = v
|
||||
}
|
||||
|
||||
function onPhaseClick(target: Phase) {
|
||||
@@ -308,9 +247,8 @@ function onPhaseClick(target: Phase) {
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await store.changePhase(slug, number, target)
|
||||
// Refresh local data to reflect the change
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
const updated = await store.changePhase(slug, number, target)
|
||||
data.value!.requirement = updated
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -319,7 +257,7 @@ async function onClose() {
|
||||
toggling.value = true
|
||||
try {
|
||||
await store.close(slug, number)
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
data.value!.requirement.status = 'closed'
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
@@ -329,7 +267,7 @@ async function onReopen() {
|
||||
toggling.value = true
|
||||
try {
|
||||
await store.reopen(slug, number)
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
data.value!.requirement.status = 'open'
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
|
||||
@@ -3,83 +3,48 @@
|
||||
<div class="flex gap-4">
|
||||
<!-- 版本列表 -->
|
||||
<div class="w-44 shrink-0">
|
||||
<NList
|
||||
v-if="artifacts.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="a in artifacts"
|
||||
:key="a.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="selectedVersion = a.version"
|
||||
>
|
||||
<NList v-if="artifacts.length > 0" :show-divider="false">
|
||||
<NListItem v-for="a in artifacts" :key="a.id">
|
||||
<NThing class="cursor-pointer" @click="selectedVersion = a.version">
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="selectedVersion === a.version ? 'text-blue-600' : ''"
|
||||
>v{{ a.version }}</span>
|
||||
<NTag
|
||||
v-if="a.source_message_id"
|
||||
size="tiny"
|
||||
class="ml-1"
|
||||
>v{{ a.version }}</span
|
||||
>
|
||||
AI
|
||||
</NTag>
|
||||
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ new Date(a.created_at).toLocaleDateString() }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="a.note"
|
||||
class="text-xs text-gray-500"
|
||||
>{{ a.note }}</span>
|
||||
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无产物版本"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无产物版本" size="small" />
|
||||
</div>
|
||||
|
||||
<!-- 选中版本内容(markdown 渲染) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-if="selectedArtifact"
|
||||
class="prose prose-sm max-w-none"
|
||||
v-html="rendered"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="text-gray-400 text-sm"
|
||||
>
|
||||
选择左侧版本以查看内容
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div v-if="selectedArtifact" class="prose prose-sm max-w-none" v-html="rendered" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<p v-else class="text-gray-400 text-sm">选择左侧版本以查看内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动提交新版本 -->
|
||||
<NDivider />
|
||||
<div
|
||||
v-if="!readonly"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div v-if="!readonly" class="space-y-2">
|
||||
<NInput
|
||||
v-model:value="newContent"
|
||||
type="textarea"
|
||||
placeholder="产物内容(Markdown)"
|
||||
:autosize="{ minRows: 3, maxRows: 10 }"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="newNote"
|
||||
placeholder="备注(可选)"
|
||||
/>
|
||||
<NInput v-model:value="newNote" placeholder="备注(可选)" />
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -90,28 +55,13 @@
|
||||
提交新版本
|
||||
</NButton>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="text-gray-400 text-sm"
|
||||
>
|
||||
需求已关闭,无法提交新版本
|
||||
</p>
|
||||
<p v-else class="text-gray-400 text-sm">需求已关闭,无法提交新版本</p>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NDivider,
|
||||
NEmpty,
|
||||
NInput,
|
||||
NList,
|
||||
NListItem,
|
||||
NTag,
|
||||
NThing,
|
||||
} from 'naive-ui'
|
||||
import { NButton, NCard, NDivider, NEmpty, NInput, NList, NListItem, NTag, NThing } from 'naive-ui'
|
||||
import type { Artifact, ArtifactPhase } from '@/api/projects'
|
||||
import { md } from '@/utils/markdown'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
|
||||
@@ -4,21 +4,10 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">关联本需求的 agent 会话</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
:loading="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
刷新
|
||||
</NButton>
|
||||
<NButton size="small" :loading="loading" @click="refresh"> 刷新 </NButton>
|
||||
<NTooltip :disabled="!!workspaceSlug">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!workspaceSlug"
|
||||
@click="goCreate"
|
||||
>
|
||||
<NButton size="small" type="primary" :disabled="!workspaceSlug" @click="goCreate">
|
||||
发起{{ phaseLabel }}会话
|
||||
</NButton>
|
||||
</template>
|
||||
@@ -27,18 +16,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NList
|
||||
v-if="sessions.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="goDetail(s)"
|
||||
>
|
||||
<NList v-if="sessions.length > 0" :show-divider="false">
|
||||
<NListItem v-for="s in sessions" :key="s.id">
|
||||
<NThing class="cursor-pointer" @click="goDetail(s)">
|
||||
<template #header>
|
||||
<span class="font-mono text-sm">{{ s.branch }}</span>
|
||||
</template>
|
||||
@@ -46,17 +26,13 @@
|
||||
<SessionStatusBadge :status="s.status" />
|
||||
</template>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ new Date(s.started_at).toLocaleString('zh-CN', { hour12: false }) }}
|
||||
{{ formatDateTime(s.started_at) }}
|
||||
<template v-if="s.last_error"> · {{ s.last_error }}</template>
|
||||
</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无关联会话"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无关联会话" size="small" />
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
@@ -68,6 +44,7 @@ import { NButton, NCard, NEmpty, NList, NListItem, NThing, NTooltip } from 'naiv
|
||||
|
||||
import { acpApi, type AcpSession } from '@/api/acp'
|
||||
import type { Requirement } from '@/api/projects'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||
|
||||
@@ -82,9 +59,7 @@ const wsStore = useWorkspacesStore()
|
||||
const sessions = ref<AcpSession[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const phaseLabel = computed(() =>
|
||||
props.requirement.phase === 'reviewing' ? '验收' : '实施',
|
||||
)
|
||||
const phaseLabel = computed(() => (props.requirement.phase === 'reviewing' ? '验收' : '实施'))
|
||||
|
||||
// requirement.workspace_id → workspace slug(路由需要 wsSlug)。
|
||||
const workspaceSlug = computed(() => {
|
||||
|
||||
@@ -15,21 +15,11 @@
|
||||
:disabled="creating"
|
||||
@update:value="onSelectConversation"
|
||||
/>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="openCreateForm"
|
||||
>
|
||||
新建会话
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" secondary @click="openCreateForm"> 新建会话 </NButton>
|
||||
</div>
|
||||
|
||||
<!-- 新建会话表单 -->
|
||||
<div
|
||||
v-if="showCreateForm"
|
||||
class="space-y-2 mb-3 border rounded p-3"
|
||||
>
|
||||
<div v-if="showCreateForm" class="space-y-2 mb-3 border rounded p-3">
|
||||
<ModelSelector v-model:model-value="newModelId" />
|
||||
<NInput
|
||||
v-model:value="newSystemPrompt"
|
||||
@@ -44,12 +34,7 @@
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="showCreateForm = false"
|
||||
>
|
||||
取消
|
||||
</NButton>
|
||||
<NButton size="small" @click="showCreateForm = false"> 取消 </NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
@@ -80,18 +65,12 @@
|
||||
@answer-question="onAnswerQuestion"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 flex items-center justify-center text-neutral-400 text-sm"
|
||||
>
|
||||
<div v-else class="flex-1 flex items-center justify-center text-neutral-400 text-sm">
|
||||
选择历史会话,或新建会话开始与 AI 讨论本阶段内容
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<div
|
||||
v-if="session.currentConversation.value"
|
||||
class="border-t pt-3 flex gap-2"
|
||||
>
|
||||
<div v-if="session.currentConversation.value" class="border-t pt-3 flex gap-2">
|
||||
<NInput
|
||||
v-model:value="draft"
|
||||
type="textarea"
|
||||
@@ -108,13 +87,7 @@
|
||||
>
|
||||
发送
|
||||
</NButton>
|
||||
<NButton
|
||||
v-else
|
||||
type="warning"
|
||||
@click="session.cancelMessage"
|
||||
>
|
||||
中断
|
||||
</NButton>
|
||||
<NButton v-else type="warning" @click="session.cancelMessage"> 中断 </NButton>
|
||||
</div>
|
||||
|
||||
<SaveArtifactModal
|
||||
@@ -134,6 +107,7 @@ import { NButton, NCard, NInput, NSelect } from 'naive-ui'
|
||||
|
||||
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
|
||||
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { useConversationSession } from '@/composables/useConversationSession'
|
||||
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
|
||||
import {
|
||||
@@ -181,7 +155,7 @@ const saveSource = ref<ChatMessage | null>(null)
|
||||
|
||||
const conversationOptions = computed(() =>
|
||||
conversations.value.map((c) => ({
|
||||
label: `${c.title || '(未命名)'} · ${new Date(c.updated_at).toLocaleString('zh-CN', { hour12: false })}`,
|
||||
label: `${c.title || '(未命名)'} · ${formatDateTime(c.updated_at)}`,
|
||||
value: c.id,
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<NCard title="产物与会话历史">
|
||||
<NSpin :show="loading">
|
||||
<NCollapse
|
||||
v-if="hasAnyContent"
|
||||
:default-expanded-names="defaultExpandedNames"
|
||||
>
|
||||
<NCollapse v-if="hasAnyContent" :default-expanded-names="defaultExpandedNames">
|
||||
<!-- 产物:按阶段分组 -->
|
||||
<NCollapseItem
|
||||
v-for="phase in ARTIFACT_PHASES"
|
||||
@@ -15,81 +12,46 @@
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ PHASE_TITLE[phase] }}产物</span>
|
||||
<NTag
|
||||
size="tiny"
|
||||
:type="phase === currentPhase ? 'primary' : 'default'"
|
||||
>
|
||||
<NTag size="tiny" :type="phase === currentPhase ? 'primary' : 'default'">
|
||||
{{ artifactsByPhase[phase].length }}
|
||||
</NTag>
|
||||
<span
|
||||
v-if="phase === currentPhase"
|
||||
class="text-xs text-blue-500"
|
||||
>当前</span>
|
||||
<span v-if="phase === currentPhase" class="text-xs text-blue-500">当前</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="artifactsByPhase[phase].length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="a in artifactsByPhase[phase]"
|
||||
:key="a.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="toggleArtifact(a)"
|
||||
>
|
||||
<NList v-if="artifactsByPhase[phase].length > 0" :show-divider="false">
|
||||
<NListItem v-for="a in artifactsByPhase[phase]" :key="a.id">
|
||||
<NThing class="cursor-pointer" @click="toggleArtifact(a)">
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="selectedArtifact?.id === a.id ? 'text-blue-600' : ''"
|
||||
>v{{ a.version }}</span>
|
||||
<NTag
|
||||
v-if="a.source_message_id"
|
||||
size="tiny"
|
||||
class="ml-1"
|
||||
>v{{ a.version }}</span
|
||||
>
|
||||
AI
|
||||
</NTag>
|
||||
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDate(a.created_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="a.note"
|
||||
class="text-xs text-gray-500"
|
||||
>{{ a.note }}</span>
|
||||
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无产物"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无产物" size="small" />
|
||||
|
||||
<!-- 选中产物内容预览 -->
|
||||
<div
|
||||
v-if="selectedArtifact && selectedArtifact.phase === phase"
|
||||
class="mt-3"
|
||||
>
|
||||
<div v-if="selectedArtifact && selectedArtifact.phase === phase" class="mt-3">
|
||||
<NDivider />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none"
|
||||
v-html="renderedArtifact"
|
||||
/>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="prose prose-sm max-w-none" v-html="renderedArtifact" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
|
||||
<!-- AI 对话 -->
|
||||
<NCollapseItem
|
||||
name="conversations"
|
||||
:disabled="conversations.length === 0"
|
||||
>
|
||||
<NCollapseItem name="conversations" :disabled="conversations.length === 0">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">AI 对话</span>
|
||||
@@ -99,18 +61,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="conversations.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="c in conversations"
|
||||
:key="c.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="goConversation(c)"
|
||||
>
|
||||
<NList v-if="conversations.length > 0" :show-divider="false">
|
||||
<NListItem v-for="c in conversations" :key="c.id">
|
||||
<NThing class="cursor-pointer" @click="goConversation(c)">
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
@@ -127,18 +80,11 @@
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无对话"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无对话" size="small" />
|
||||
</NCollapseItem>
|
||||
|
||||
<!-- ACP 会话 -->
|
||||
<NCollapseItem
|
||||
name="acp-sessions"
|
||||
:disabled="acpSessions.length === 0"
|
||||
>
|
||||
<NCollapseItem name="acp-sessions" :disabled="acpSessions.length === 0">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">ACP 会话</span>
|
||||
@@ -148,14 +94,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="acpSessions.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="s in acpSessions"
|
||||
:key="s.id"
|
||||
>
|
||||
<NList v-if="acpSessions.length > 0" :show-divider="false">
|
||||
<NListItem v-for="s in acpSessions" :key="s.id">
|
||||
<NThing
|
||||
:class="workspaceSlug ? 'cursor-pointer' : ''"
|
||||
@click="workspaceSlug ? goAcpSession(s) : undefined"
|
||||
@@ -173,19 +113,11 @@
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无会话"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无会话" size="small" />
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
|
||||
<NEmpty
|
||||
v-else-if="!loading"
|
||||
description="暂无历史产物与会话"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else-if="!loading" description="暂无历史产物与会话" size="small" />
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</template>
|
||||
@@ -210,6 +142,7 @@ import { chatApi, type Conversation } from '@/api/chat'
|
||||
import { acpApi, type AcpSession } from '@/api/acp'
|
||||
import type { Artifact, ArtifactPhase, Phase } from '@/api/projects'
|
||||
import { md } from '@/utils/markdown'
|
||||
import { formatDate, formatDateTime } from '@/utils/date'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
|
||||
@@ -258,8 +191,9 @@ const artifactsByPhase = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
const hasAnyContent = computed(() =>
|
||||
artifacts.value.length > 0 || conversations.value.length > 0 || acpSessions.value.length > 0,
|
||||
const hasAnyContent = computed(
|
||||
() =>
|
||||
artifacts.value.length > 0 || conversations.value.length > 0 || acpSessions.value.length > 0,
|
||||
)
|
||||
|
||||
// 默认展开有内容的第一项(优先当前阶段)
|
||||
@@ -313,14 +247,6 @@ function goAcpSession(s: AcpSession) {
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDateTime(d: string) {
|
||||
return new Date(d).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,48 +1,28 @@
|
||||
<template>
|
||||
<NCard title="产物汇总">
|
||||
<NSpin :show="loading">
|
||||
<div
|
||||
v-if="grouped.length > 0"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div
|
||||
v-for="g in grouped"
|
||||
:key="g.phase"
|
||||
>
|
||||
<div v-if="grouped.length > 0" class="space-y-4">
|
||||
<div v-for="g in grouped" :key="g.phase">
|
||||
<h3 class="text-sm font-semibold mb-2">
|
||||
{{ PHASE_TITLE[g.phase] }}({{ g.items.length }} 个版本)
|
||||
</h3>
|
||||
<NList :show-divider="false">
|
||||
<NListItem
|
||||
v-for="a in g.items"
|
||||
:key="a.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="selected = selected?.id === a.id ? null : a"
|
||||
>
|
||||
<NListItem v-for="a in g.items" :key="a.id">
|
||||
<NThing class="cursor-pointer" @click="selected = selected?.id === a.id ? null : a">
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="selected?.id === a.id ? 'text-blue-600' : ''"
|
||||
>v{{ a.version }}</span>
|
||||
<NTag
|
||||
v-if="a.source_message_id"
|
||||
size="tiny"
|
||||
class="ml-1"
|
||||
>v{{ a.version }}</span
|
||||
>
|
||||
AI
|
||||
</NTag>
|
||||
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ new Date(a.created_at).toLocaleString('zh-CN', { hour12: false }) }}
|
||||
{{ formatDateTime(a.created_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="a.note"
|
||||
class="text-xs text-gray-500"
|
||||
>{{ a.note }}</span>
|
||||
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
@@ -50,17 +30,12 @@
|
||||
|
||||
<template v-if="selected">
|
||||
<NDivider />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none"
|
||||
v-html="rendered"
|
||||
/>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="prose prose-sm max-w-none" v-html="rendered" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</template>
|
||||
</div>
|
||||
<NEmpty
|
||||
v-else-if="!loading"
|
||||
description="暂无任何阶段产物"
|
||||
/>
|
||||
<NEmpty v-else-if="!loading" description="暂无任何阶段产物" />
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</template>
|
||||
@@ -70,6 +45,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { NCard, NDivider, NEmpty, NList, NListItem, NSpin, NTag, NThing } from 'naive-ui'
|
||||
import type { Artifact, ArtifactPhase } from '@/api/projects'
|
||||
import { md } from '@/utils/markdown'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
|
||||
const PHASE_TITLE: Record<ArtifactPhase, string> = {
|
||||
|
||||
@@ -8,10 +8,7 @@
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<NTabs type="segment">
|
||||
<NTabPane
|
||||
name="edit"
|
||||
tab="编辑"
|
||||
>
|
||||
<NTabPane name="edit" tab="编辑">
|
||||
<NInput
|
||||
v-model:value="content"
|
||||
type="textarea"
|
||||
@@ -19,31 +16,19 @@
|
||||
:autosize="{ minRows: 12, maxRows: 24 }"
|
||||
/>
|
||||
</NTabPane>
|
||||
<NTabPane
|
||||
name="preview"
|
||||
tab="预览"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<NTabPane name="preview" tab="预览">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none max-h-[60vh] overflow-y-auto border rounded p-3"
|
||||
v-html="rendered"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
<NInput
|
||||
v-model:value="note"
|
||||
placeholder="版本备注(可选)"
|
||||
/>
|
||||
<NInput v-model:value="note" placeholder="版本备注(可选)" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton @click="emit('update:show', false)">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!content.trim()"
|
||||
@click="onSave"
|
||||
>
|
||||
<NButton @click="emit('update:show', false)"> 取消 </NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="!content.trim()" @click="onSave">
|
||||
保存为新版本
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,8 @@
|
||||
<!-- 目标选择:main 或某个 worktree -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500">查看目标</span>
|
||||
<NSelect
|
||||
v-model:value="target"
|
||||
:options="targetOptions"
|
||||
size="small"
|
||||
class="w-64"
|
||||
/>
|
||||
<NButton
|
||||
size="small"
|
||||
:loading="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
刷新
|
||||
</NButton>
|
||||
<NSelect v-model:value="target" :options="targetOptions" size="small" class="w-64" />
|
||||
<NButton size="small" :loading="loading" @click="refresh"> 刷新 </NButton>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
@@ -35,6 +24,7 @@ import type { DataTableColumns } from 'naive-ui'
|
||||
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import type { CommitLog, Workspace } from '@/api/types'
|
||||
|
||||
const props = defineProps<{ ws: Workspace }>()
|
||||
@@ -52,10 +42,6 @@ const targetOptions = computed(() => {
|
||||
return opts
|
||||
})
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function shortHash(hash: string) {
|
||||
return hash.slice(0, 8)
|
||||
}
|
||||
@@ -67,11 +53,7 @@ const columns = computed<DataTableColumns<CommitLog>>(() => [
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
render(row: CommitLog) {
|
||||
return h(
|
||||
NTag,
|
||||
{ size: 'tiny', type: 'default' },
|
||||
{ default: () => shortHash(row.hash) },
|
||||
)
|
||||
return h(NTag, { size: 'tiny', type: 'default' }, { default: () => shortHash(row.hash) })
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -99,7 +81,7 @@ const columns = computed<DataTableColumns<CommitLog>>(() => [
|
||||
key: 'date',
|
||||
width: 160,
|
||||
render(row: CommitLog) {
|
||||
return h('span', { class: 'text-xs text-gray-500' }, formatDate(row.date))
|
||||
return h('span', { class: 'text-xs text-gray-500' }, formatDateTime(row.date))
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user