ACP / MCP 完善

This commit is contained in:
2026-06-11 12:43:18 +08:00
parent 1415d3b43b
commit 0561e126cd
22 changed files with 786 additions and 219 deletions
+28 -6
View File
@@ -67,15 +67,37 @@ describe('AcpSessionCreateView', () => {
it('mounts without error', async () => {
const wrapper = mount(AcpSessionCreateView)
await new Promise((r) => setTimeout(r, 20))
expect(wrapper.html()).toContain('New ACP Session')
expect(wrapper.html()).toContain('新建 ACP 会话')
})
it('prefills requirement/issue number from query', async () => {
routeMock.query = { requirement_number: '7', issue_number: '42' }
it('prefills issue number from query', async () => {
routeMock.query = { issue_number: '42' }
const wrapper = mount(AcpSessionCreateView)
await new Promise((r) => setTimeout(r, 20))
const inputs = wrapper.findAll('input[type="number"]')
expect((inputs[0].element as HTMLInputElement).value).toBe('42')
expect((inputs[1].element as HTMLInputElement).value).toBe('7')
const issueInput = wrapper.find('[data-testid="issue-number-input"] input')
const reqInput = wrapper.find('[data-testid="requirement-number-input"] input')
expect((issueInput.element as HTMLInputElement).value).toBe('42')
expect((reqInput.element as HTMLInputElement).value).toBe('')
})
it('prefills requirement number from query', async () => {
routeMock.query = { requirement_number: '7' }
const wrapper = mount(AcpSessionCreateView)
await new Promise((r) => setTimeout(r, 20))
const issueInput = wrapper.find('[data-testid="issue-number-input"] input')
const reqInput = wrapper.find('[data-testid="requirement-number-input"] input')
expect((issueInput.element as HTMLInputElement).value).toBe('')
expect((reqInput.element as HTMLInputElement).value).toBe('7')
})
it('rejects submit when issue # and requirement # are both filled', async () => {
const wrapper = mount(AcpSessionCreateView)
await new Promise((r) => setTimeout(r, 20))
const issueInput = wrapper.find('[data-testid="issue-number-input"] input')
const reqInput = wrapper.find('[data-testid="requirement-number-input"] input')
await issueInput.setValue('42')
await reqInput.setValue('7')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.text()).toContain('最多只能填写一项')
})
})
+137 -98
View File
@@ -1,6 +1,17 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
NButton,
NForm,
NFormItem,
NInput,
NInputNumber,
NSelect,
NSpin,
} from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useAcpStore } from '@/stores/acp'
import { useWorkspacesStore } from '@/stores/workspaces'
@@ -12,36 +23,62 @@ const wsStore = useWorkspacesStore()
const form = ref({
agent_kind_id: '',
branch: '',
issue_number: '' as string,
requirement_number: '' as string,
issue_number: null as number | null,
requirement_number: null as number | null,
initial_prompt: '',
})
const submitting = ref(false)
const errorMsg = ref<string | null>(null)
const projectSlug = computed(() => route.params.slug as string)
const workspaceSlug = computed(() => route.params.wsSlug as string)
const agentKindOptions = computed(() =>
acpStore.enabledAgentKinds.map((k) => ({
label: k.display_name,
value: k.id,
})),
)
const exclusiveCount = computed(
() =>
[form.value.branch, form.value.issue_number, form.value.requirement_number].filter(Boolean)
.length,
)
const canSubmit = computed(
() => !!form.value.agent_kind_id && exclusiveCount.value <= 1 && !submitting.value,
)
onMounted(async () => {
// 来自需求详情页等入口的预填(?requirement_number= / ?issue_number=)。
if (typeof route.query.requirement_number === 'string') {
form.value.requirement_number = route.query.requirement_number
form.value.requirement_number = Number(route.query.requirement_number)
}
if (typeof route.query.issue_number === 'string') {
form.value.issue_number = route.query.issue_number
form.value.issue_number = Number(route.query.issue_number)
}
await acpStore.listAgentKinds(false)
if (wsStore.list.length === 0) {
await wsStore.fetchByProject(route.params.slug as string)
await wsStore.fetchByProject(projectSlug.value)
}
})
async function submit() {
errorMsg.value = null
if (exclusiveCount.value > 1) {
errorMsg.value = 'Branch、Issue # 和 Requirement # 最多只能填写一项'
return
}
const ws = wsStore.list.find((w) => w.slug === workspaceSlug.value)
if (!ws) {
errorMsg.value = '未找到工作区,请刷新页面后重试'
return
}
submitting.value = true
try {
const ws = wsStore.list.find((w) => w.slug === route.params.wsSlug)
if (!ws) {
errorMsg.value = 'Workspace not found; please reload the page.'
return
}
const req: {
workspace_id: string
agent_kind_id: string
@@ -54,114 +91,116 @@ async function submit() {
agent_kind_id: form.value.agent_kind_id,
}
if (form.value.branch) req.branch = form.value.branch
if (form.value.issue_number) req.issue_number = Number(form.value.issue_number)
if (form.value.requirement_number)
req.requirement_number = Number(form.value.requirement_number)
if (form.value.issue_number != null) req.issue_number = form.value.issue_number
if (form.value.requirement_number != null) req.requirement_number = form.value.requirement_number
if (form.value.initial_prompt) req.initial_prompt = form.value.initial_prompt
const sess = await acpStore.createSession(req)
router.push(
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/${sess.id}`,
)
router.push({
name: 'acp-sessions-detail',
params: {
slug: projectSlug.value,
wsSlug: workspaceSlug.value,
sessionId: sess.id,
},
})
} catch (e) {
errorMsg.value = `Create failed: ${e instanceof Error ? e.message : 'unknown'}`
errorMsg.value = `创建失败:${e instanceof Error ? e.message : 'unknown'}`
} finally {
submitting.value = false
}
}
function cancel() {
router.push(
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions`,
)
router.push({
name: 'acp-sessions-list',
params: { slug: projectSlug.value, wsSlug: workspaceSlug.value },
})
}
</script>
<template>
<div class="p-6 max-w-2xl">
<h1 class="text-2xl font-semibold mb-4">New ACP Session</h1>
<AppShell>
<NSpin :show="acpStore.loading || wsStore.loading">
<div class="p-6 max-w-2xl">
<h1 class="text-2xl font-semibold mb-4">
新建 ACP 会话
</h1>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="block text-sm font-medium mb-1">Agent Kind</label>
<select
v-model="form.agent_kind_id"
class="block w-full border rounded px-2 py-1 text-sm"
required
>
<option value="" disabled>-- choose --</option>
<option
v-for="k in acpStore.enabledAgentKinds"
:key="k.id"
:value="k.id"
<NForm @submit.prevent="submit">
<NFormItem
label="Agent Kind"
required
>
{{ k.display_name }}
</option>
</select>
</div>
<NSelect
v-model:value="form.agent_kind_id"
:options="agentKindOptions"
placeholder="请选择 Agent Kind"
/>
</NFormItem>
<div>
<label class="block text-sm font-medium mb-1">
Branch <span class="text-xs text-gray-500">(optional, takes priority)</span>
</label>
<input
v-model="form.branch"
class="block w-full border rounded px-2 py-1 text-sm font-mono"
placeholder="feat/x or main"
/>
</div>
<NFormItem label="Branch">
<NInput
v-model:value="form.branch"
placeholder="feat/x 或 main"
/>
</NFormItem>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium mb-1">Issue # (optional)</label>
<input
v-model="form.issue_number"
type="number"
class="block w-full border rounded px-2 py-1 text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Requirement # (optional)</label>
<input
v-model="form.requirement_number"
type="number"
class="block w-full border rounded px-2 py-1 text-sm"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<NFormItem label="Issue #">
<NInputNumber
v-model:value="form.issue_number"
:min="1"
placeholder="可选"
data-testid="issue-number-input"
/>
</NFormItem>
<NFormItem label="Requirement #">
<NInputNumber
v-model:value="form.requirement_number"
:min="1"
placeholder="可选"
data-testid="requirement-number-input"
/>
</NFormItem>
</div>
<p class="text-xs text-gray-500">
Branch resolution: explicit &gt; issue &gt; requirement &gt; auto temp branch.
</p>
<p class="text-xs text-gray-500 mb-4">
BranchIssue #Requirement # 最多填写一项全部留空将使用自动临时分支
</p>
<div>
<label class="block text-sm font-medium mb-1">Initial Prompt (optional)</label>
<textarea
v-model="form.initial_prompt"
rows="3"
class="block w-full border rounded px-2 py-1 text-sm"
placeholder="Send this immediately after agent ready..."
/>
</div>
<NFormItem label="Initial Prompt">
<NInput
v-model:value="form.initial_prompt"
type="textarea"
:rows="3"
placeholder="会话启动后立即发送给 Agent 的提示词(可选)"
/>
</NFormItem>
<p v-if="errorMsg" class="text-sm text-red-600">{{ errorMsg }}</p>
<div class="flex justify-end gap-2">
<NButton @click="cancel">
取消
</NButton>
<NButton
type="primary"
attr-type="submit"
:loading="submitting"
:disabled="!canSubmit"
data-testid="submit-btn"
>
创建会话
</NButton>
</div>
</NForm>
<div class="flex gap-2">
<button
type="submit"
class="px-3 py-1 rounded text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300"
:disabled="submitting || !form.agent_kind_id"
<p
v-if="errorMsg"
class="text-red-500 text-sm mt-2"
>
{{ submitting ? 'Creating...' : 'Create Session' }}
</button>
<button
type="button"
class="px-3 py-1 rounded text-sm border hover:bg-gray-50"
@click="cancel"
>
Cancel
</button>
{{ errorMsg }}
</p>
</div>
</form>
</div>
</NSpin>
</AppShell>
</template>