Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3332458708 | ||
|
|
ff2eb47302 |
No files matched your search
+15
-2
@@ -1,7 +1,7 @@
|
||||
# AITURK Chrome beta
|
||||
|
||||
Windows / Chrome MV3 side panel for existing local AITURK IDE conversations.
|
||||
Requires AITURK IDE 1.0.0-beta.7 or later; see `KURULUM.txt` for Turkish setup.
|
||||
Requires AITURK IDE 1.0.0-beta.9 or later; see `KURULUM.txt` for Turkish setup.
|
||||
|
||||
The extension talks only to `online.turkservis.aiturk` using Chrome native
|
||||
messaging. The per-user .NET Framework host authenticates to Electron's random
|
||||
@@ -12,7 +12,8 @@ runtime identity, then submits only the new prompt. No parallel agent backend,
|
||||
history reconstruction, model API key in the browser or telemetry is involved.
|
||||
|
||||
Only the active local profile is supported. The list searches titles among
|
||||
desktop sessions in the most recent 200 records. The transcript shows the last
|
||||
the most recent 200 desktop sessions (two API pages of at most 100, filtered
|
||||
server-side before pagination). The transcript shows the last
|
||||
40 records, with up to 8,000 characters per visible message; the model's existing
|
||||
context is not truncated by this display policy. Approvals and clarifications
|
||||
remain in the IDE. A backend event is stamped once and delivered to every
|
||||
@@ -40,6 +41,18 @@ npm ci --workspace apps/desktop --include-workspace-root
|
||||
node --test apps/chrome/tests/panel.test.mjs
|
||||
```
|
||||
|
||||
Exercise the production FastAPI session router and real temporary SQLite stores
|
||||
with the JS bridge (no model requests or user data):
|
||||
|
||||
```powershell
|
||||
$env:AITURK_TEST_PYTHON = 'C:/path/to/python-with-hermes-dependencies.exe'
|
||||
node --test apps/chrome/tests/sessions-api.test.mjs
|
||||
```
|
||||
|
||||
This contract test reproduces the original 422 for an oversized page, then
|
||||
verifies 200 desktop conversations across two valid pages, profile isolation,
|
||||
short/empty lists, archived/empty/cron exclusion and bounded pagination.
|
||||
|
||||
Run backend fan-out and replay tests with the canonical hermetic runner:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -94,6 +94,11 @@ async function fixture(t) {
|
||||
const http = createServer((request, response) => {
|
||||
assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret')
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
if (request.url.startsWith('/api/sessions?')) {
|
||||
const query = new URL(request.url, 'http://localhost').searchParams
|
||||
assert.ok(Number(query.get('limit')) <= 100)
|
||||
assert.equal(query.get('source'), 'desktop')
|
||||
}
|
||||
if (request.url.startsWith('/api/sessions?')) response.end(JSON.stringify({ sessions: [
|
||||
{ id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' },
|
||||
{ id: 'stored-2', title: 'İkinci iş', source: 'desktop' },
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Real session router + disposable SQLite stores for the JS bridge contract.
|
||||
|
||||
Run by sessions-api.test.mjs. Only service dependencies are replaced; query
|
||||
validation, route handling, SQL filters and pagination are production code.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="aiturk-chrome-api-") as directory:
|
||||
os.environ["HERMES_HOME"] = directory
|
||||
from fastapi import FastAPI, HTTPException, WebSocket
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli.web_routers import sessions
|
||||
|
||||
paths = {name: Path(directory) / f"{name}.db" for name in ("default", "small", "empty")}
|
||||
for profile, count in (("default", 230), ("small", 3), ("empty", 0)):
|
||||
db = SessionDB(db_path=paths[profile])
|
||||
for index in range(count):
|
||||
sid = f"{profile}-desktop-{index:03d}"
|
||||
db.create_session(sid, source="desktop")
|
||||
db.set_session_title(sid, sid)
|
||||
db.append_message(sid, role="user", content="Fixture context")
|
||||
if count:
|
||||
for index in range(240):
|
||||
sid = f"{profile}-cron-{index:03d}"
|
||||
db.create_session(sid, source="cron")
|
||||
db.set_session_title(sid, sid)
|
||||
db.append_message(sid, role="user", content="Not a desktop conversation")
|
||||
db.create_session(f"{profile}-blank", source="desktop")
|
||||
db.create_session(f"{profile}-archived", source="desktop")
|
||||
db.append_message(f"{profile}-archived", role="user", content="Archived")
|
||||
db._conn.execute("UPDATE sessions SET archived=1 WHERE id=?", (f"{profile}-archived",))
|
||||
db._conn.commit()
|
||||
db.close()
|
||||
|
||||
def profile_home(profile):
|
||||
if profile not in paths:
|
||||
raise HTTPException(status_code=404)
|
||||
return profile, Path(directory)
|
||||
|
||||
sessions._cron_profile_home = profile_home
|
||||
sessions._cron_default_profile = lambda: "default"
|
||||
sessions._maybe_auto_archive_for_profile = lambda profile: None
|
||||
sessions._open_session_db_for_profile = lambda profile, read_only=True: SessionDB(
|
||||
db_path=paths[profile or "default"], read_only=read_only
|
||||
)
|
||||
# The fixture stores no system/model secrets; list projection is unrelated
|
||||
# to this contract and must not import the full web-server startup path.
|
||||
sessions._strip_session_list_rows = lambda rows: None
|
||||
app = FastAPI()
|
||||
app.include_router(sessions.list_router)
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth(request, call_next):
|
||||
if request.headers.get("X-Hermes-Session-Token") != "fixture-only":
|
||||
return JSONResponse({}, status_code=401)
|
||||
return await call_next(request)
|
||||
|
||||
@app.websocket("/api/ws")
|
||||
async def ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
server = uvicorn.Server(uvicorn.Config(app, log_level="critical"))
|
||||
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.monotonic() + 20
|
||||
while not server.started:
|
||||
if not thread.is_alive() or time.monotonic() > deadline:
|
||||
raise RuntimeError("Fixture failed to start")
|
||||
time.sleep(0.01)
|
||||
print(f"READY {port}", flush=True)
|
||||
# EOF shuts down cleanly, including Windows venv launcher/worker pairs.
|
||||
sys.stdin.read()
|
||||
server.should_exit = True
|
||||
thread.join(timeout=15)
|
||||
sock.close()
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { test } from 'node:test'
|
||||
import { ChromeGatewayClient } from '../../desktop/electron/chrome-bridge.mjs'
|
||||
|
||||
test('real FastAPI/SQLite: valid bounded desktop pages, profile scope and empty states', {
|
||||
skip: !process.env.AITURK_TEST_PYTHON && 'Set AITURK_TEST_PYTHON to run the real API contract',
|
||||
timeout: 90000,
|
||||
}, async t => {
|
||||
// No credentials/config inherited by the disposable Python service.
|
||||
const env = Object.fromEntries(['PATH', 'SYSTEMROOT', 'TEMP', 'TMP', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA']
|
||||
.filter(key => process.env[key]).map(key => [key, process.env[key]]))
|
||||
env.PYTHONUTF8 = '1'
|
||||
const child = spawn(process.env.AITURK_TEST_PYTHON,
|
||||
[fileURLToPath(new URL('./sessions-api-fixture.py', import.meta.url))], { env, windowsHide: true })
|
||||
const exited = once(child, 'exit')
|
||||
let stdout = '', stderr = ''
|
||||
child.stderr.on('data', chunk => { stderr += chunk })
|
||||
t.after(async () => { child.stdin.end(); await exited })
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Fixture startup timeout: ${stderr}`)), 45000)
|
||||
child.once('error', error => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', () => { clearTimeout(timer); reject(new Error(`Fixture exited: ${stderr}`)) })
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk
|
||||
const match = stdout.match(/READY (\d+)/)
|
||||
if (match) { clearTimeout(timer); resolve(Number(match[1])) }
|
||||
})
|
||||
})
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const old = await fetch(`${baseUrl}/api/sessions?limit=200`, { headers: { 'X-Hermes-Session-Token': 'fixture-only' } })
|
||||
assert.equal(old.status, 422, 'production validation reproduces the reported failure')
|
||||
assert.ok((await old.json()).detail.some(error => error.loc.includes('limit')))
|
||||
for (const [profile, expected, requests] of [['default', 200, 2], ['small', 3, 1], ['empty', 0, 1]]) {
|
||||
const client = new ChromeGatewayClient(async () => ({ baseUrl, wsUrl: `${baseUrl.replace('http:', 'ws:')}/api/ws`,
|
||||
token: 'fixture-only', mode: 'local', profile }), () => {})
|
||||
const paths = []
|
||||
const realRest = client.rest.bind(client)
|
||||
client.rest = path => { paths.push(path); return realRest(path) }
|
||||
try {
|
||||
const result = await client.handle({ id: 'contract', action: 'sessions.list' })
|
||||
assert.equal(result.sessions.length, expected)
|
||||
assert.equal(new Set(result.sessions.map(row => row.id)).size, expected)
|
||||
assert.ok(result.sessions.every(row => row.id.startsWith(`${profile}-desktop-`)))
|
||||
assert.equal(result.profile, profile)
|
||||
assert.equal(paths.length, requests)
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const query = new URL(path, baseUrl).searchParams
|
||||
assert.equal(query.get('offset'), String(index * 100))
|
||||
assert.equal(query.get('source'), 'desktop')
|
||||
assert.equal(query.get('profile'), profile)
|
||||
}
|
||||
} finally { client.close() }
|
||||
}
|
||||
})
|
||||
@@ -159,10 +159,23 @@ export class ChromeGatewayClient {
|
||||
case 'status':
|
||||
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
|
||||
case 'sessions.list': {
|
||||
const query = new URLSearchParams({ ...this.scope(), limit: '200', offset: '0', min_messages: '1', archived: 'exclude', order: 'recent' })
|
||||
const data = await this.rest(`/api/sessions?${query}`)
|
||||
const rows = Array.isArray(data) ? data : data.sessions || []
|
||||
return { sessions: rows.slice(0, 200).filter(row => row.source === 'desktop').map(row => ({
|
||||
const rows = [], seen = new Set()
|
||||
// The real sessions API validates limit <= 100. Filter before paging:
|
||||
// cron/CLI traffic must not crowd IDE conversations out of the list.
|
||||
for (let offset = 0; offset < 200; offset += 100) {
|
||||
const query = new URLSearchParams({ ...this.scope(), limit: '100', offset: String(offset),
|
||||
source: 'desktop', min_messages: '1', archived: 'exclude', order: 'recent' })
|
||||
const data = await this.rest(`/api/sessions?${query}`)
|
||||
const page = Array.isArray(data) ? data : data.sessions || []
|
||||
for (const row of page) {
|
||||
const id = row.id || row.session_id
|
||||
if (row.source !== 'desktop' || !id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
rows.push(row)
|
||||
}
|
||||
if (page.length < 100) break
|
||||
}
|
||||
return { sessions: rows.slice(0, 200).map(row => ({
|
||||
id: row.id || row.session_id, title: clip(row.title || row.preview || 'İsimsiz konuşma', 180),
|
||||
model: clip(row.model, 160), updatedAt: row.last_active || row.started_at,
|
||||
})), profile: this.backend.profile || 'default', limited: true }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.8",
|
||||
"version": "1.0.0-beta.10",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
setModelVisibilityOpen,
|
||||
setVisibleModels
|
||||
} from '@/store/model-visibility'
|
||||
import { openPreview } from '@/store/preview'
|
||||
import type { LocalRuntimeJob } from '@/types/hermes'
|
||||
|
||||
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
|
||||
@@ -24,6 +25,7 @@ beforeAll(() => {
|
||||
})
|
||||
|
||||
const getGlobalModelOptions = vi.fn()
|
||||
vi.mock('@/store/preview', () => ({ openPreview: vi.fn() }))
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
|
||||
@@ -88,6 +90,22 @@ function renderMenu() {
|
||||
// the kanban board would end up disagreeing about what "my models" means —
|
||||
// which is exactly the drift extracting this component was meant to prevent.
|
||||
describe('the catalog owns model curation', () => {
|
||||
it('opens the chosen TurkServis model details without selecting that model', async () => {
|
||||
getGlobalModelOptions.mockResolvedValue({ providers: [{
|
||||
models: ['Outer'], name: 'TurkServis', slug: 'turkservis', api_url: 'https://ai.turkservis.online/v1'
|
||||
}] })
|
||||
const select = renderMenu()
|
||||
const trigger = await screen.findByRole('menuitem', { name: /Outer/ })
|
||||
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowRight' })
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /Model \/ combo (details|ayrıntıları)/ }))
|
||||
|
||||
expect(openPreview).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: 'url', url: 'https://turkservis.online/router?model=Outer'
|
||||
}))
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honours the stored Edit Models shortlist', async () => {
|
||||
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
||||
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
||||
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { turkservisComboDetailsUrl } from '@/lib/turkservis-combo-details'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $localModelsEnabled } from '@/store/local-models-flag'
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
modelVisibilityKey,
|
||||
setModelVisibilityOpen
|
||||
} from '@/store/model-visibility'
|
||||
import { openPreview } from '@/store/preview'
|
||||
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
|
||||
import { $defaultReasoningEffort } from '@/store/session'
|
||||
import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
@@ -588,6 +590,12 @@ export function ModelCatalogMenu({
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
model={family.id}
|
||||
onOpenDetails={turkservisComboDetailsUrl(group.provider.api_url, family.id) ? () => {
|
||||
const url = turkservisComboDetailsUrl(group.provider.api_url, family.id)!
|
||||
|
||||
closeMenu()
|
||||
openPreview({ kind: 'url', label: t.shell.modelOptions.comboDetails, source: url, url })
|
||||
} : undefined}
|
||||
onSelectModel={nextModel => controller.select(nextModel, group.provider.slug)}
|
||||
onSetOptions={patch =>
|
||||
controller.setOptions(patch, {
|
||||
|
||||
@@ -29,6 +29,7 @@ function renderSubmenu(opts: {
|
||||
fastControl: FastControl
|
||||
isActive?: boolean
|
||||
onSelectModel?: (model: string) => void
|
||||
onOpenDetails?: () => void
|
||||
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
|
||||
reasoning: boolean
|
||||
}) {
|
||||
@@ -43,6 +44,7 @@ function renderSubmenu(opts: {
|
||||
fastControl={opts.fastControl}
|
||||
isActive={opts.isActive ?? true}
|
||||
model="m1"
|
||||
onOpenDetails={opts.onOpenDetails}
|
||||
onSelectModel={opts.onSelectModel ?? vi.fn()}
|
||||
onSetOptions={opts.onSetOptions}
|
||||
provider="p1"
|
||||
@@ -60,6 +62,19 @@ function renderSubmenu(opts: {
|
||||
// ever writes directly again, picking an effort for a kanban card would reach
|
||||
// over and change the user's live chat.
|
||||
describe('ModelEditSubmenu reports edits without performing them', () => {
|
||||
it('opens read-only details without selecting a model or changing its options', () => {
|
||||
const onOpenDetails = vi.fn()
|
||||
const onSelectModel = vi.fn()
|
||||
const onSetOptions = vi.fn()
|
||||
|
||||
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: false, onOpenDetails, onSelectModel, onSetOptions })
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Model \/ combo (details|ayrıntıları)/ }))
|
||||
|
||||
expect(onOpenDetails).toHaveBeenCalledOnce()
|
||||
expect(onSelectModel).not.toHaveBeenCalled()
|
||||
expect(onSetOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('param fast: reports the toggle', () => {
|
||||
const onSetOptions = vi.fn()
|
||||
renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false })
|
||||
|
||||
@@ -75,6 +75,8 @@ interface ModelEditSubmenuProps {
|
||||
isActive: boolean
|
||||
/** This row's model id. */
|
||||
model: string
|
||||
/** Read-only details; the owner handles opening without changing selection. */
|
||||
onOpenDetails?: () => void
|
||||
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||
onSelectModel: (model: string) => Promise<boolean | void> | void
|
||||
/** Report an option change. This submenu is PURE: it never writes to a
|
||||
@@ -107,6 +109,7 @@ function ModelEditSubmenuBody({
|
||||
effort,
|
||||
fastControl,
|
||||
isActive,
|
||||
onOpenDetails,
|
||||
onSelectModel,
|
||||
onSetOptions,
|
||||
reasoning
|
||||
@@ -140,11 +143,19 @@ function ModelEditSubmenuBody({
|
||||
const hasFast = fastControl.kind !== 'none'
|
||||
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
|
||||
|
||||
return !hasFast && !reasoning ? (
|
||||
return !hasFast && !reasoning && !onOpenDetails ? (
|
||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">{copy.noOptions}</div>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel>
|
||||
{onOpenDetails ? (
|
||||
<>
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={onOpenDetails}>
|
||||
{copy.comboDetails}
|
||||
</DropdownMenuItem>
|
||||
{hasFast || reasoning ? <DropdownMenuSeparator className="mx-0" /> : null}
|
||||
</>
|
||||
) : null}
|
||||
{hasFast || reasoning ? <DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel> : null}
|
||||
{showThinkingToggle ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
{copy.thinking}
|
||||
|
||||
@@ -2235,6 +2235,7 @@ export const ar = defineLocale({
|
||||
fast: 'سريع'
|
||||
},
|
||||
modelOptions: {
|
||||
comboDetails: 'تفاصيل النموذج / المجموعة',
|
||||
noOptions: 'لا توجد خيارات لهذا النموذج',
|
||||
options: 'الخيارات',
|
||||
thinking: 'التفكير',
|
||||
|
||||
@@ -2999,6 +2999,7 @@ export const en: Translations = {
|
||||
fast: 'Fast'
|
||||
},
|
||||
modelOptions: {
|
||||
comboDetails: 'Model / combo details',
|
||||
noOptions: 'No options for this model',
|
||||
options: 'Options',
|
||||
thinking: 'Thinking',
|
||||
|
||||
@@ -2624,6 +2624,7 @@ export const ja = defineLocale({
|
||||
fast: '高速'
|
||||
},
|
||||
modelOptions: {
|
||||
comboDetails: 'モデル / コンボの詳細',
|
||||
noOptions: 'このモデルにはオプションがありません',
|
||||
options: 'オプション',
|
||||
thinking: '思考',
|
||||
|
||||
@@ -3022,6 +3022,7 @@ export const ru = defineLocale({
|
||||
fast: 'Быстрая'
|
||||
},
|
||||
modelOptions: {
|
||||
comboDetails: 'Сведения о модели / комбинации',
|
||||
noOptions: 'Для этой модели нет опций',
|
||||
options: 'Опции',
|
||||
thinking: 'Размышление',
|
||||
|
||||
@@ -1281,6 +1281,7 @@ export const trOverrides: TranslationOverrides = {
|
||||
fast: 'Hızlı'
|
||||
},
|
||||
modelOptions: {
|
||||
comboDetails: 'Model / combo ayrıntıları',
|
||||
noOptions: 'Bu model için seçenek yok',
|
||||
options: 'Seçenekler',
|
||||
thinking: 'Düşünme',
|
||||
|
||||
@@ -2546,6 +2546,7 @@ export interface Translations {
|
||||
fast: string
|
||||
}
|
||||
modelOptions: {
|
||||
comboDetails: string
|
||||
noOptions: string
|
||||
options: string
|
||||
thinking: string
|
||||
|
||||
@@ -2523,6 +2523,7 @@ export const zhHant = defineLocale({
|
||||
fast: '快速'
|
||||
},
|
||||
modelOptions: {
|
||||
comboDetails: '模型 / 組合詳細資料',
|
||||
noOptions: '此模型沒有可用選項',
|
||||
options: '選項',
|
||||
thinking: '思考',
|
||||
|
||||
@@ -3147,6 +3147,7 @@ export const zh: Translations = {
|
||||
fast: '快速'
|
||||
},
|
||||
modelOptions: {
|
||||
comboDetails: '模型 / 组合详情',
|
||||
noOptions: '此模型没有可用选项',
|
||||
options: '选项',
|
||||
thinking: '思考',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { turkservisComboDetailsUrl } from './turkservis-combo-details'
|
||||
|
||||
describe('TurkServis read-only details link', () => {
|
||||
it('preserves the exact model and drops API query parameters', () => {
|
||||
const result = turkservisComboDetailsUrl('https://ai.turkservis.online/v1?key=private', '9router-Team & A')!
|
||||
|
||||
expect(new URL(result).searchParams.get('model')).toBe('9router-Team & A')
|
||||
expect(new URL(result).origin).toBe('https://turkservis.online')
|
||||
expect(result).not.toContain('private')
|
||||
})
|
||||
|
||||
it.each([undefined, '', 'invalid', 'http://ai.turkservis.online/v1',
|
||||
'https://ai.turkservis.online.evil.test/v1', 'https://other.test/v1',
|
||||
'https://user:secret@ai.turkservis.online/v1', 'https://ai.turkservis.online:9090/v1'
|
||||
])('does not offer details for an unrelated or unsafe endpoint: %s', apiUrl => {
|
||||
expect(turkservisComboDetailsUrl(apiUrl, 'Team')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects missing and oversized model identities', () => {
|
||||
expect(turkservisComboDetailsUrl('https://turkservis.online/v1', '')).toBeNull()
|
||||
expect(turkservisComboDetailsUrl('https://turkservis.online/v1', 'x'.repeat(129))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Details use the member portal's own login; API credentials never enter a URL. */
|
||||
export function turkservisComboDetailsUrl(apiUrl: string | undefined, model: string): string | null {
|
||||
if (!apiUrl || !model || model.length > 128) {return null}
|
||||
|
||||
try {
|
||||
const endpoint = new URL(apiUrl)
|
||||
|
||||
if (
|
||||
endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.port ||
|
||||
!['ai.turkservis.online', 'turkservis.online'].includes(endpoint.hostname)
|
||||
) {return null}
|
||||
|
||||
const details = new URL('https://turkservis.online/router')
|
||||
|
||||
details.searchParams.set('model', model)
|
||||
|
||||
return details.href
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.8",
|
||||
"version": "1.0.0-beta.10",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
Reference in new issue
Block a user