fix: add mdtodo provider profile selection
This commit is contained in:
@@ -7,7 +7,9 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { assertCloudWebDistFresh, buildCloudWebDist, cloudWebDistAliasFiles, inspectCloudWebDistFreshness } from "./dist-contract.ts";
|
||||
|
||||
test("Cloud Web dist build emits Vue assets and route aliases", async () => {
|
||||
const DIST_CONTRACT_TEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
test("Cloud Web dist build emits Vue assets and route aliases", { timeout: DIST_CONTRACT_TEST_TIMEOUT_MS }, async () => {
|
||||
const rootDir = makeCloudWebFixture();
|
||||
try {
|
||||
const distDir = await buildCloudWebDist(rootDir);
|
||||
@@ -34,7 +36,7 @@ test("Cloud Web dist build emits Vue assets and route aliases", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Cloud Web dist freshness reports stale generated app asset", async () => {
|
||||
test("Cloud Web dist freshness reports stale generated app asset", { timeout: DIST_CONTRACT_TEST_TIMEOUT_MS }, async () => {
|
||||
const rootDir = makeCloudWebFixture();
|
||||
try {
|
||||
await buildCloudWebDist(rootDir);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { providerProfileOptionsFromPayload } from "../src/stores/workbench-session.ts";
|
||||
|
||||
test("Workbench provider catalog marks missing selected profile as unavailable", () => {
|
||||
const options = providerProfileOptionsFromPayload({ profiles: [{ profile: "dsflash-go", label: "DeepSeek V4 Flash", configured: true }] }, "codex");
|
||||
|
||||
const available = options.find((option) => option.value === "dsflash-go");
|
||||
const missingSelected = options.find((option) => option.value === "codex");
|
||||
|
||||
assert.equal(available?.configured, true);
|
||||
assert.equal(missingSelected?.configured, false);
|
||||
});
|
||||
@@ -63,6 +63,7 @@ export interface WorkbenchLaunchContext {
|
||||
mdtodoRootRef?: string | null;
|
||||
hwpodWorkspaceArgs?: string | null;
|
||||
contextFingerprint?: string | null;
|
||||
providerProfile?: string | null;
|
||||
capabilities?: Record<string, unknown>;
|
||||
executionContext?: WorkbenchLaunchExecutionContext | null;
|
||||
bodyPreview?: string | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useMdtodoTaskMutation } from "@/composables/mdtodo/useMdtodoTaskMutatio
|
||||
import { useMdtodoReportPreview } from "@/composables/mdtodo/useMdtodoReportPreview";
|
||||
import { useMdtodoWorkbenchLaunch } from "@/composables/mdtodo/useMdtodoWorkbenchLaunch";
|
||||
import { fileDisplayName } from "@/composables/mdtodo/useMdtodoDisplay";
|
||||
import { useWorkbenchStore } from "@/stores/workbench";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -40,6 +41,7 @@ const taskData = useMdtodoTaskData();
|
||||
const mutation = useMdtodoTaskMutation();
|
||||
const report = useMdtodoReportPreview();
|
||||
const launch = useMdtodoWorkbenchLaunch(router);
|
||||
const workbench = useWorkbenchStore();
|
||||
|
||||
// Selection state (URL is authority; these mirror the URL)
|
||||
const selectedSourceId = ref<string | null>(null);
|
||||
@@ -54,6 +56,8 @@ const taskPaneCollapsed = ref(false);
|
||||
const taskPaneWidth = ref(26);
|
||||
const reportPaneCollapsed = ref(false);
|
||||
const reportPaneWidth = ref(50);
|
||||
const providerOptionsReady = ref(false);
|
||||
const providerOptionsError = ref<string | null>(null);
|
||||
|
||||
// Dialog visibility
|
||||
const showInfo = ref(false);
|
||||
@@ -109,7 +113,22 @@ const selectedTaskBodyHtml = computed(() => report.markdownToHtml(selectedTaskBo
|
||||
const reportPreviewHtml = computed(() => report.markdownToHtml(report.reportPreview.value?.content ?? ""));
|
||||
const selectedFileName = computed(() => selectedFile.value ? fileDisplayName(selectedFile.value) : selectedTask.value?.fileRef || "-");
|
||||
const reportPaneOpen = computed(() => Boolean(report.reportPreview.value));
|
||||
const workbenchLaunchEnabled = computed(() => launch.isLaunchEnabled(source.navigation.value));
|
||||
const selectedProviderValue = computed(() => String(workbench.providerProfile || "").trim());
|
||||
const selectedProviderOption = computed(() => workbench.providerOptions.find((option) => option.value === selectedProviderValue.value) ?? null);
|
||||
const providerLaunchBlocker = computed(() => {
|
||||
if (!providerOptionsReady.value) return "模型通道校验中";
|
||||
if (providerOptionsError.value) return providerOptionsError.value;
|
||||
if (!selectedProviderValue.value) return "模型通道未选择";
|
||||
const option = selectedProviderOption.value;
|
||||
if (option?.configured === false) return `模型通道 ${option.label || option.value} 未配置`;
|
||||
return null;
|
||||
});
|
||||
const workbenchLaunchCapabilityEnabled = computed(() => launch.isLaunchEnabled(source.navigation.value));
|
||||
const workbenchLaunchBlocker = computed(() => {
|
||||
if (!workbenchLaunchCapabilityEnabled.value) return "Workbench Launch capability unavailable";
|
||||
return providerLaunchBlocker.value;
|
||||
});
|
||||
const workbenchLaunchEnabled = computed(() => !workbenchLaunchBlocker.value);
|
||||
const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLaunchEnabled.value || launch.launchLoading.value);
|
||||
const selectedSourceCanReindex = computed(() => Boolean(selectedSourceId.value && !taskData.taskLoading.value && !source.sourceReindexLoading.value));
|
||||
function reportLinksOnly(links: MdtodoTaskLinkRecord[]): MdtodoTaskLinkRecord[] {
|
||||
@@ -131,7 +150,25 @@ function projectedReportLink(task: MdtodoTaskRecord, linkCount: number): MdtodoT
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => void loadPage());
|
||||
onMounted(() => {
|
||||
void refreshMdtodoProviderOptions();
|
||||
void loadPage();
|
||||
});
|
||||
|
||||
async function refreshMdtodoProviderOptions(): Promise<void> {
|
||||
providerOptionsReady.value = false;
|
||||
providerOptionsError.value = null;
|
||||
const ok = await workbench.refreshProviderOptions();
|
||||
providerOptionsReady.value = true;
|
||||
if (!ok) {
|
||||
providerOptionsError.value = "模型通道目录不可用";
|
||||
return;
|
||||
}
|
||||
const current = selectedProviderOption.value;
|
||||
if (current?.configured !== false) return;
|
||||
const fallback = workbench.providerOptions.find((option) => option.configured !== false);
|
||||
if (fallback) workbench.setProviderProfile(fallback.value);
|
||||
}
|
||||
|
||||
watch(selectedSourceId, async (sourceId) => {
|
||||
if (!sourceId || loading.value || selection.applyingRouteSelection.value) return;
|
||||
@@ -353,7 +390,7 @@ function closeReport(): void {
|
||||
async function launchWorkbench(): Promise<void> {
|
||||
const task = selectedTask.value;
|
||||
if (!task || launchButtonDisabled.value) return;
|
||||
const result = await launch.launchTask(task, selectedTaskBody.value, selectedTaskLinks.value, selectedFileName.value, source.navigation.value);
|
||||
const result = await launch.launchTask(task, selectedTaskBody.value, selectedTaskLinks.value, selectedFileName.value, source.navigation.value, selectedProviderValue.value);
|
||||
if (result) {
|
||||
await taskData.loadLinks(task.taskRef);
|
||||
await launch.navigateToWorkbench(result);
|
||||
@@ -478,7 +515,11 @@ function setError(err: unknown): void {
|
||||
:launch-loading="launch.launchLoading.value"
|
||||
:launch-error="launch.launchError.value"
|
||||
:launch-enabled="workbenchLaunchEnabled"
|
||||
:launch-blocker="workbenchLaunchBlocker"
|
||||
:provider-profile="workbench.providerProfile"
|
||||
:provider-options="workbench.providerOptions"
|
||||
:navigation="source.navigation.value"
|
||||
@update:provider-profile="workbench.setProviderProfile($event)"
|
||||
@update:edit-title="editTitle = $event"
|
||||
@update:edit-status="editStatus = $event"
|
||||
@update:edit-body="editBody = $event"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { MdtodoTaskDetailRecord, MdtodoTaskLinkRecord, MdtodoTaskRecord, ProjectNavigationResponse } from "@/api";
|
||||
import type { ProviderProfile } from "@/types";
|
||||
import type { ProviderProfileOption } from "@/stores/workbench-session";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
|
||||
@@ -27,6 +29,9 @@ const props = defineProps<{
|
||||
launchLoading: boolean;
|
||||
launchError: string | null;
|
||||
launchEnabled: boolean;
|
||||
launchBlocker: string | null;
|
||||
providerProfile: ProviderProfile;
|
||||
providerOptions: ProviderProfileOption[];
|
||||
navigation: ProjectNavigationResponse | null;
|
||||
}>();
|
||||
|
||||
@@ -34,6 +39,7 @@ const emit = defineEmits<{
|
||||
"update:editTitle": [value: string];
|
||||
"update:editStatus": [value: string];
|
||||
"update:editBody": [value: string];
|
||||
"update:providerProfile": [value: ProviderProfile];
|
||||
beginTitleEdit: [];
|
||||
cancelTitleEdit: [];
|
||||
saveTaskBasics: [];
|
||||
@@ -80,6 +86,14 @@ function normalizeTaskText(value?: string | null): string {
|
||||
<option value="blocked">阻塞</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-status-save" :disabled="mutationLoading" @click="emit('saveTaskBasics')">保存状态</button>
|
||||
<label class="provider-field" for="mdtodo-provider-profile">
|
||||
<span>模型通道</span>
|
||||
<select id="mdtodo-provider-profile" :value="providerProfile" data-testid="mdtodo-provider-profile" :disabled="launchLoading" aria-label="模型通道" @change="emit('update:providerProfile', ($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="option in providerOptions" :key="option.value" :value="option.value" :disabled="option.configured === false">
|
||||
{{ option.label }}{{ option.configured === false ? ' · 未配置' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="!task || !launchEnabled || launchLoading" :aria-disabled="!task || !launchEnabled || launchLoading" @click="emit('launchWorkbench')">{{ launchLoading ? '启动中' : '在 Workbench 执行' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -91,7 +105,7 @@ function normalizeTaskText(value?: string | null): string {
|
||||
<p v-if="mutationMessage" class="source-message" data-testid="mdtodo-task-mutation-message">{{ mutationMessage }}</p>
|
||||
<p v-if="mutationError" class="source-error" data-testid="mdtodo-task-mutation-error">{{ mutationError }}</p>
|
||||
<p v-if="launchError" class="launch-blocker" data-testid="mdtodo-workbench-launch-error">{{ launchError }}</p>
|
||||
<p v-else-if="!launchEnabled" class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">Workbench Launch capability unavailable</p>
|
||||
<p v-else-if="launchBlocker" class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ launchBlocker }}</p>
|
||||
</div>
|
||||
|
||||
<section class="task-detail-content" data-testid="mdtodo-task-editor">
|
||||
@@ -147,6 +161,9 @@ function normalizeTaskText(value?: string | null): string {
|
||||
.inline-editor input { width: 100%; min-width: 0; border: 1px solid #cbd6dd; border-radius: 6px; color: #111827; font: inherit; padding: 8px 9px; }
|
||||
.detail-toolbar { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.detail-toolbar select { min-width: 108px; height: 32px; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; padding: 0 8px; font: inherit; font-size: 13px; }
|
||||
.provider-field { display: flex; min-width: 218px; align-items: center; gap: 6px; color: #5d6a73; font-size: 12px; font-weight: 850; }
|
||||
.provider-field span { white-space: nowrap; }
|
||||
.provider-field select { flex: 1 1 150px; min-width: 150px; }
|
||||
.task-status-stack { display: grid; min-width: 0; gap: 6px; }
|
||||
.task-detail-content { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); gap: 0; overflow: hidden; }
|
||||
.task-detail-status { display: grid; min-width: 0; gap: 6px; }
|
||||
@@ -178,6 +195,8 @@ function normalizeTaskText(value?: string | null): string {
|
||||
@media (max-width: 640px) {
|
||||
.mdtodo-detail-header { flex-direction: column; align-items: stretch; }
|
||||
.detail-toolbar { justify-content: flex-start; }
|
||||
.provider-field { width: 100%; justify-content: space-between; }
|
||||
.provider-field select { flex: 1 1 auto; }
|
||||
.task-title-block { grid-template-columns: 1fr; }
|
||||
.inline-editor { grid-template-columns: 1fr; }
|
||||
.task-document-footer { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface WorkbenchLaunchContext {
|
||||
mdtodoRootRef?: string | null;
|
||||
hwpodWorkspaceArgs?: string | null;
|
||||
contextFingerprint?: string | null;
|
||||
providerProfile?: string | null;
|
||||
capabilities?: Record<string, unknown>;
|
||||
executionContext?: WorkbenchLaunchExecutionContext | null;
|
||||
bodyPreview: string;
|
||||
@@ -136,7 +137,8 @@ export function useMdtodoWorkbenchLaunch(router: Router) {
|
||||
body: string,
|
||||
links: MdtodoTaskLinkRecord[],
|
||||
fileName: string,
|
||||
navigation: ProjectNavigationResponse | null
|
||||
navigation: ProjectNavigationResponse | null,
|
||||
providerProfile: string
|
||||
): Promise<{ sessionId: string; workbenchUrl: string } | null> {
|
||||
if (!task.taskRef || !task.projectId) return null;
|
||||
const enabled = navigation?.navigation?.capabilities?.workbenchLaunch === true;
|
||||
@@ -144,14 +146,19 @@ export function useMdtodoWorkbenchLaunch(router: Router) {
|
||||
launchError.value = "Workbench Launch capability unavailable";
|
||||
return null;
|
||||
}
|
||||
const selectedProviderProfile = providerProfile.trim();
|
||||
if (!selectedProviderProfile) {
|
||||
launchError.value = "模型通道未选择";
|
||||
return null;
|
||||
}
|
||||
launchLoading.value = true;
|
||||
launchError.value = null;
|
||||
try {
|
||||
const launchContext = buildContext(task, body, links, fileName);
|
||||
const launchContext = { ...buildContext(task, body, links, fileName), providerProfile: selectedProviderProfile };
|
||||
const response = await workbenchAPI.launch({
|
||||
projectId: task.projectId,
|
||||
taskRef: task.taskRef,
|
||||
providerProfile: "codex",
|
||||
providerProfile: selectedProviderProfile,
|
||||
launchContext
|
||||
});
|
||||
if (!response.ok) throw response;
|
||||
@@ -163,7 +170,7 @@ export function useMdtodoWorkbenchLaunch(router: Router) {
|
||||
message: prompt,
|
||||
prompt,
|
||||
sessionId,
|
||||
providerProfile: "codex",
|
||||
providerProfile: selectedProviderProfile,
|
||||
projectId: task.projectId,
|
||||
taskRef: task.taskRef,
|
||||
launchContext: authoritativeLaunchContext
|
||||
|
||||
@@ -233,7 +233,8 @@ export function providerProfileOptionsFromPayload(payload: unknown, selectedProf
|
||||
const record = payload && typeof payload === "object" ? payload as { profiles?: unknown; items?: unknown; data?: { profiles?: unknown; items?: unknown } } : {};
|
||||
const raw = Array.isArray(record.profiles) ? record.profiles : Array.isArray(record.items) ? record.items : Array.isArray(record.data?.profiles) ? record.data.profiles : Array.isArray(record.data?.items) ? record.data.items : [];
|
||||
const parsed = raw.map((item) => profileOptionFromUnknown(item)).filter((item): item is ProviderProfileOption => Boolean(item));
|
||||
return ensureSelectedProviderProfile(parsed.length > 0 ? parsed.sort(compareProviderProfileOptions) : defaultProviderProfileOptions(selectedProfile), selectedProfile);
|
||||
if (parsed.length > 0) return ensureSelectedProviderProfile(parsed.sort(compareProviderProfileOptions), selectedProfile, true);
|
||||
return defaultProviderProfileOptions(selectedProfile);
|
||||
}
|
||||
|
||||
export function defaultProviderProfileOptions(selectedProfile: ProviderProfile): ProviderProfileOption[] {
|
||||
@@ -356,11 +357,11 @@ function profileOptionFromUnknown(item: unknown): ProviderProfileOption | null {
|
||||
return { value, label: firstNonEmptyString(record.label, record.displayName, record.title) ?? providerProfileLabel(value), configured: typeof record.configured === "boolean" ? record.configured : undefined };
|
||||
}
|
||||
|
||||
function ensureSelectedProviderProfile(options: ProviderProfileOption[], selectedProfile: ProviderProfile): ProviderProfileOption[] {
|
||||
function ensureSelectedProviderProfile(options: ProviderProfileOption[], selectedProfile: ProviderProfile, markMissingUnconfigured = false): ProviderProfileOption[] {
|
||||
const selected = firstNonEmptyString(selectedProfile);
|
||||
const unique = uniqueProviderOptions(options);
|
||||
if (!selected || unique.some((option) => option.value === selected)) return unique;
|
||||
return uniqueProviderOptions([...unique, { value: selected, label: providerProfileLabel(selected) }]);
|
||||
return uniqueProviderOptions([...unique, { value: selected, label: providerProfileLabel(selected), configured: markMissingUnconfigured ? false : undefined }]);
|
||||
}
|
||||
|
||||
function uniqueProviderOptions(options: ProviderProfileOption[]): ProviderProfileOption[] {
|
||||
|
||||
@@ -1111,9 +1111,10 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
|
||||
localStorage.setItem("hwlab.workbench.providerProfile.v1", value);
|
||||
}
|
||||
|
||||
async function refreshProviderOptions(): Promise<void> {
|
||||
async function refreshProviderOptions(): Promise<boolean> {
|
||||
const response = await api.providerProfiles.catalog();
|
||||
providerOptions.value = response.ok ? providerProfileOptionsFromPayload(response.data, providerProfile.value) : defaultProviderProfileOptions(providerProfile.value);
|
||||
return response.ok === true;
|
||||
}
|
||||
|
||||
function rememberRecentDraft(text: string): void {
|
||||
|
||||
Reference in New Issue
Block a user