fix: add mdtodo provider profile selection

This commit is contained in:
UniDesk Codex
2026-06-30 12:51:37 +08:00
parent 30dbd22819
commit 12f3d11104
8 changed files with 100 additions and 14 deletions
@@ -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);
});
+1
View File
@@ -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[] {
+2 -1
View File
@@ -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 {