Files
gpustack-ui/src/pages/llmodels/components/model-item.tsx
T
gitlawr bda8264788 fix: playground model id alignment across org boundaries
Two QA-reported bugs on the "Open in Playground" path. Both come
down to the model id the playground submits not matching what
``/v1/models`` / the dispatcher key off:

* Routes page (OSS UI) emitted ``default/<name>`` for routes in the
  platform Org and 404'd. ``use-open-playground`` keyed off the
  ``is_platform`` flag alone; stale caches that drop the flag
  slipped through. Also accept the well-known ``name === 'default'``
  (``PLATFORM_PRINCIPAL_NAME`` on the backend) as a fallback signal.

* My Models page (enterprise UI, non-admin) emitted bare ``<name>``
  with no Org prefix for non-platform routes. Use ``model.name``
  from ``/v2/my-models`` verbatim — the backend ("fix: principal
  prefix in my-models") now rewrites that field to the OpenAI-style
  id server-side, which also closes the cross-Org grant gap a
  client-side cache lookup can't (the granting Org isn't in the
  caller's member list). Card title now shows the prefixed id, so
  users can tell apart same-named models from different Orgs.

Routes page keeps ``useOpenPlayground`` — that surface is always
scoped to the caller's own Org, the local cache is sufficient, and
``/model-routes`` still returns the raw ``name``.

Also drops the now-unused ``onClick`` prop on ``ModelItem``: the
card had ``clickable={false}`` so the parent-passed handler was
already dead code; the Button drives the playground navigation.
2026-06-07 15:45:49 +08:00

256 lines
6.4 KiB
TypeScript

import {
IconFont,
StatusTag,
TagsWrapper,
TemplateCard,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl, useNavigate } from '@umijs/max';
import { Button } from 'antd';
import _ from 'lodash';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { categoryConfig } from '../../_components/model-tag';
import {
modelCategories,
modelCategoriesMap,
modelSourceMap,
MyModelsStatusLabelMap,
MyModelsStatusMap,
MyModelsStatusValueMap
} from '../config';
import { categoryToPathMap } from '../config/button-actions';
const CardWrapper = styled.div`
&:hover {
.content {
.btn {
display: block;
}
}
}
`;
const Dot = styled.span`
background-color: var(--ant-color-text-quaternary);
border-radius: 50%;
flex: none;
height: 3px;
width: 3px;
`;
const ModelItemContent = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
cursor: default;
.content {
display: flex;
justify-content: space-between;
flex: 1;
}
.footer {
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
.btn {
display: none;
}
}
.time {
color: var(--ant-color-text-secondary);
font-size: var(--font-size-small);
font-weight: 400;
}
.extra-info {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
.tag-item {
margin-right: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
height: 22px;
opacity: 0.7;
}
}
`;
const Header = styled.div`
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.anticon {
font-size: 16px;
color: var(--ant-color-text-secondary);
}
.text {
display: flex;
align-items: center;
font-size: var(--font-size-middle);
font-weight: 500;
color: var(--ant-color-text);
}
`;
const sourceIconMap = {
[modelSourceMap.local_path_value]: 'icon-hard-disk',
[modelSourceMap.huggingface_value]: 'icon-huggingface',
[modelSourceMap.modelscope_value]: 'icon-tu2'
};
const renderTag = (item: any, index = 0) => {
return (
<ThemeTag key={item} className="tag-item" color="purple">
{item}
</ThemeTag>
);
};
const ModelItem: React.FC<{
model: Record<string, any>;
}> = (props) => {
const { model } = props;
const intl = useIntl();
const navigate = useNavigate();
// ``model.name`` from ``/v2/my-models`` is the OpenAI-style id
// (org-prefixed for non-platform routes, bare for platform). Use it
// verbatim — the playground / dispatcher both key off that exact id.
const handleOpenPlayGroundClick = () => {
const modelName = encodeURIComponent(model.name);
for (const [category, path] of Object.entries(categoryToPathMap)) {
if (
model.categories?.includes(category) &&
[
modelCategoriesMap.text_to_speech,
modelCategoriesMap.speech_to_text
].includes(category)
) {
navigate(`${path}&model=${modelName}`);
return;
}
if (model.categories?.includes(category)) {
navigate(`${path}?model=${modelName}`);
return;
}
}
navigate(`/playground/chat?model=${modelName}`);
};
// context length
const maxToken = useMemo(() => {
const meta = model.meta || {};
const { max_model_len, n_ctx, n_slot, max_total_tokens } = meta || {};
let max_tokens: number = 0;
if (n_ctx && n_slot) {
max_tokens = _.divide(n_ctx, n_slot);
} else if (max_model_len) {
max_tokens = max_model_len;
} else if (max_total_tokens) {
max_tokens = max_total_tokens;
}
return _.round(max_tokens / 1024);
}, [model]);
return (
<CardWrapper>
<TemplateCard
height={140}
clickable={false}
hoverable={true}
ghost
header={
<Header>
<span className="text gap-8">
<IconFont
type={sourceIconMap[model.source]}
style={{ fontSize: 24 }}
/>
<span>{model.name}</span>
</span>
<StatusTag
maxTooltipWidth={400}
statusValue={{
status: MyModelsStatusMap[model.status],
text: intl.formatMessage({
id: MyModelsStatusLabelMap[model.status] || ''
}),
message: model.state_message
}}
></StatusTag>
</Header>
}
>
<ModelItemContent>
<div className="content">
<div className="footer">
<div className="extra-info">
{model.categories?.length > 0 &&
model.categories.map((sItem: string) => {
return (
<ThemeTag
icon={categoryConfig[sItem]?.icon}
key={sItem}
className="tag-item"
color={categoryConfig[sItem]?.color || 'blue'}
opacity={0.7}
>
{_.find(modelCategories, { value: sItem })?.label ||
sItem}
</ThemeTag>
);
})}
{maxToken > 0 && (
<>
<Dot></Dot>
<ThemeTag className="tag-item" color="purple">
{maxToken}K context
</ThemeTag>
</>
)}
{model.meta?.voices?.length > 0 && (
<>
<Dot></Dot>
<TagsWrapper
gap={8}
dataList={model.meta?.voices}
renderTag={renderTag}
></TagsWrapper>
</>
)}
</div>
{[MyModelsStatusValueMap.Active].includes(model.status) && (
<Button
size="middle"
className="btn"
type="primary"
onClick={handleOpenPlayGroundClick}
>
{intl.formatMessage({ id: 'models.openinplayground' })}
</Button>
)}
</div>
</div>
</ModelItemContent>
</TemplateCard>
</CardWrapper>
);
};
export default ModelItem;