Compare commits

...
21 changed files with 320 additions and 12 deletions

No files matched your search

+15 -2
View File
@@ -1,7 +1,7 @@
# AITURK Chrome beta # AITURK Chrome beta
Windows / Chrome MV3 side panel for existing local AITURK IDE conversations. 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 The extension talks only to `online.turkservis.aiturk` using Chrome native
messaging. The per-user .NET Framework host authenticates to Electron's random 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. history reconstruction, model API key in the browser or telemetry is involved.
Only the active local profile is supported. The list searches titles among 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 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 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 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 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: Run backend fan-out and replay tests with the canonical hermetic runner:
```bash ```bash
+16
View File
@@ -78,11 +78,27 @@ test('page context is explicit appended reference data, never a replaced system
assert.ok(prompt.includes('Ignore prior instructions')) assert.ok(prompt.includes('Ignore prior instructions'))
}) })
test('40 long Unicode or escaped transcripts fit the native byte limit with an in-flight answer', () => {
for (const text of ['Türkçe ığüşöç', '你好世界', '\u0000\u0001']) {
const rows = Array.from({ length: 40 }, (_, id) => ({ id, role: 'assistant', content: text.repeat(8000) }))
const response = { id: 'request', result: { messages: publicMessages(rows), inflight: '\u0000'.repeat(16000), model: 'fixture' } }
assert.ok(Buffer.byteLength(JSON.stringify(response)) < MAX_FRAME)
assert.equal(response.result.messages.length, 40)
assert.ok(response.result.messages.every(row => row.text.length > 0))
assert.equal(rows[0].content, text.repeat(8000))
}
})
async function fixture(t) { async function fixture(t) {
const calls = [], peers = [] const calls = [], peers = []
const http = createServer((request, response) => { const http = createServer((request, response) => {
assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret') assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret')
response.setHeader('Content-Type', 'application/json') 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: [ if (request.url.startsWith('/api/sessions?')) response.end(JSON.stringify({ sessions: [
{ id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' }, { id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' },
{ id: 'stored-2', title: 'İkinci iş', source: 'desktop' }, { id: 'stored-2', title: 'İkinci iş', source: 'desktop' },
+93
View File
@@ -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()
+57
View File
@@ -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() }
}
})
+29 -6
View File
@@ -12,13 +12,23 @@ const contentText = value => typeof value === 'string' ? value : Array.isArray(v
? value.filter(part => part?.type === 'text').map(part => part.text || '').join('\n') : '' ? value.filter(part => part?.type === 'text').map(part => part.text || '').join('\n') : ''
export function publicMessages(rows) { export function publicMessages(rows) {
return (Array.isArray(rows) ? rows : []).filter(row => const messages = (Array.isArray(rows) ? rows : []).filter(row =>
['user', 'assistant'].includes(row.role) && row.display_kind !== 'hidden' ['user', 'assistant'].includes(row.role) && row.display_kind !== 'hidden'
).slice(-40).map(row => ({ ).slice(-40).map(row => ({
id: row.id ?? row.row_id, id: typeof (row.id ?? row.row_id) === 'number' ? (row.id ?? row.row_id) : undefined,
role: row.role, role: row.role,
text: clip(contentText(row.display_content ?? row.content ?? row.text)), text: clip(contentText(row.display_content ?? row.content ?? row.text)),
})) }))
// The Chrome native limit counts UTF-8 JSON bytes, not JS characters.
// Reserve room for the opening response's in-flight text and metadata;
// escaped controls and CJK text must not disconnect a large transcript.
const messageBudget = Math.floor((MAX_FRAME - 128000) / Math.max(1, messages.length))
for (const message of messages) {
while (Buffer.byteLength(JSON.stringify(message)) > messageBudget) {
message.text = message.text.slice(0, Math.floor(message.text.length * 0.8))
}
}
return messages
} }
export function validateRequest(message) { export function validateRequest(message) {
@@ -149,10 +159,23 @@ export class ChromeGatewayClient {
case 'status': case 'status':
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true } return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
case 'sessions.list': { case 'sessions.list': {
const query = new URLSearchParams({ ...this.scope(), limit: '200', offset: '0', min_messages: '1', archived: 'exclude', order: 'recent' }) const rows = [], seen = new Set()
const data = await this.rest(`/api/sessions?${query}`) // The real sessions API validates limit <= 100. Filter before paging:
const rows = Array.isArray(data) ? data : data.sessions || [] // cron/CLI traffic must not crowd IDE conversations out of the list.
return { sessions: rows.slice(0, 200).filter(row => row.source === 'desktop').map(row => ({ 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), 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, model: clip(row.model, 160), updatedAt: row.last_active || row.started_at,
})), profile: this.backend.profile || 'default', limited: true } })), profile: this.backend.profile || 'default', limited: true }
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide", "name": "aiturk-ide",
"productName": "AITURK IDE", "productName": "AITURK IDE",
"private": true, "private": true,
"version": "1.0.0-beta.7", "version": "1.0.0-beta.10",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis", "author": "AITURK / TurkServis",
"repository": { "repository": {
@@ -12,6 +12,7 @@ import {
setModelVisibilityOpen, setModelVisibilityOpen,
setVisibleModels setVisibleModels
} from '@/store/model-visibility' } from '@/store/model-visibility'
import { openPreview } from '@/store/preview'
import type { LocalRuntimeJob } from '@/types/hermes' import type { LocalRuntimeJob } from '@/types/hermes'
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu' import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
@@ -24,6 +25,7 @@ beforeAll(() => {
}) })
const getGlobalModelOptions = vi.fn() const getGlobalModelOptions = vi.fn()
vi.mock('@/store/preview', () => ({ openPreview: vi.fn() }))
vi.mock('@/hermes', () => ({ vi.mock('@/hermes', () => ({
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args), getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
@@ -88,6 +90,22 @@ function renderMenu() {
// the kanban board would end up disagreeing about what "my models" means — // the kanban board would end up disagreeing about what "my models" means —
// which is exactly the drift extracting this component was meant to prevent. // which is exactly the drift extracting this component was meant to prevent.
describe('the catalog owns model curation', () => { 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 () => { it('honours the stored Edit Models shortlist', async () => {
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')])) 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 { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import { normalize } from '@/lib/text' import { normalize } from '@/lib/text'
import { turkservisComboDetailsUrl } from '@/lib/turkservis-combo-details'
import { useStoreSelector } from '@/lib/use-session-slice' import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $localModelsEnabled } from '@/store/local-models-flag' import { $localModelsEnabled } from '@/store/local-models-flag'
@@ -38,6 +39,7 @@ import {
modelVisibilityKey, modelVisibilityKey,
setModelVisibilityOpen setModelVisibilityOpen
} from '@/store/model-visibility' } from '@/store/model-visibility'
import { openPreview } from '@/store/preview'
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
import { $defaultReasoningEffort } from '@/store/session' import { $defaultReasoningEffort } from '@/store/session'
import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
@@ -588,6 +590,12 @@ export function ModelCatalogMenu({
fastControl={fastControl} fastControl={fastControl}
isActive={isCurrent} isActive={isCurrent}
model={family.id} 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)} onSelectModel={nextModel => controller.select(nextModel, group.provider.slug)}
onSetOptions={patch => onSetOptions={patch =>
controller.setOptions(patch, { controller.setOptions(patch, {
@@ -29,6 +29,7 @@ function renderSubmenu(opts: {
fastControl: FastControl fastControl: FastControl
isActive?: boolean isActive?: boolean
onSelectModel?: (model: string) => void onSelectModel?: (model: string) => void
onOpenDetails?: () => void
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
reasoning: boolean reasoning: boolean
}) { }) {
@@ -43,6 +44,7 @@ function renderSubmenu(opts: {
fastControl={opts.fastControl} fastControl={opts.fastControl}
isActive={opts.isActive ?? true} isActive={opts.isActive ?? true}
model="m1" model="m1"
onOpenDetails={opts.onOpenDetails}
onSelectModel={opts.onSelectModel ?? vi.fn()} onSelectModel={opts.onSelectModel ?? vi.fn()}
onSetOptions={opts.onSetOptions} onSetOptions={opts.onSetOptions}
provider="p1" provider="p1"
@@ -60,6 +62,19 @@ function renderSubmenu(opts: {
// ever writes directly again, picking an effort for a kanban card would reach // ever writes directly again, picking an effort for a kanban card would reach
// over and change the user's live chat. // over and change the user's live chat.
describe('ModelEditSubmenu reports edits without performing them', () => { 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', () => { it('param fast: reports the toggle', () => {
const onSetOptions = vi.fn() const onSetOptions = vi.fn()
renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false }) renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false })
@@ -75,6 +75,8 @@ interface ModelEditSubmenuProps {
isActive: boolean isActive: boolean
/** This row's model id. */ /** This row's model id. */
model: string 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). */ /** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean | void> | void onSelectModel: (model: string) => Promise<boolean | void> | void
/** Report an option change. This submenu is PURE: it never writes to a /** Report an option change. This submenu is PURE: it never writes to a
@@ -107,6 +109,7 @@ function ModelEditSubmenuBody({
effort, effort,
fastControl, fastControl,
isActive, isActive,
onOpenDetails,
onSelectModel, onSelectModel,
onSetOptions, onSetOptions,
reasoning reasoning
@@ -140,11 +143,19 @@ function ModelEditSubmenuBody({
const hasFast = fastControl.kind !== 'none' const hasFast = fastControl.kind !== 'none'
const fastOn = fastControl.kind === 'none' ? false : fastControl.on 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> <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 ? ( {showThinkingToggle ? (
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}> <DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
{copy.thinking} {copy.thinking}
+1
View File
@@ -2235,6 +2235,7 @@ export const ar = defineLocale({
fast: 'سريع' fast: 'سريع'
}, },
modelOptions: { modelOptions: {
comboDetails: 'تفاصيل النموذج / المجموعة',
noOptions: 'لا توجد خيارات لهذا النموذج', noOptions: 'لا توجد خيارات لهذا النموذج',
options: 'الخيارات', options: 'الخيارات',
thinking: 'التفكير', thinking: 'التفكير',
+1
View File
@@ -2999,6 +2999,7 @@ export const en: Translations = {
fast: 'Fast' fast: 'Fast'
}, },
modelOptions: { modelOptions: {
comboDetails: 'Model / combo details',
noOptions: 'No options for this model', noOptions: 'No options for this model',
options: 'Options', options: 'Options',
thinking: 'Thinking', thinking: 'Thinking',
+1
View File
@@ -2624,6 +2624,7 @@ export const ja = defineLocale({
fast: '高速' fast: '高速'
}, },
modelOptions: { modelOptions: {
comboDetails: 'モデル / コンボの詳細',
noOptions: 'このモデルにはオプションがありません', noOptions: 'このモデルにはオプションがありません',
options: 'オプション', options: 'オプション',
thinking: '思考', thinking: '思考',
+1
View File
@@ -3022,6 +3022,7 @@ export const ru = defineLocale({
fast: 'Быстрая' fast: 'Быстрая'
}, },
modelOptions: { modelOptions: {
comboDetails: 'Сведения о модели / комбинации',
noOptions: 'Для этой модели нет опций', noOptions: 'Для этой модели нет опций',
options: 'Опции', options: 'Опции',
thinking: 'Размышление', thinking: 'Размышление',
+1
View File
@@ -1281,6 +1281,7 @@ export const trOverrides: TranslationOverrides = {
fast: 'Hızlı' fast: 'Hızlı'
}, },
modelOptions: { modelOptions: {
comboDetails: 'Model / combo ayrıntıları',
noOptions: 'Bu model için seçenek yok', noOptions: 'Bu model için seçenek yok',
options: 'Seçenekler', options: 'Seçenekler',
thinking: 'Düşünme', thinking: 'Düşünme',
+1
View File
@@ -2546,6 +2546,7 @@ export interface Translations {
fast: string fast: string
} }
modelOptions: { modelOptions: {
comboDetails: string
noOptions: string noOptions: string
options: string options: string
thinking: string thinking: string
+1
View File
@@ -2523,6 +2523,7 @@ export const zhHant = defineLocale({
fast: '快速' fast: '快速'
}, },
modelOptions: { modelOptions: {
comboDetails: '模型 / 組合詳細資料',
noOptions: '此模型沒有可用選項', noOptions: '此模型沒有可用選項',
options: '選項', options: '選項',
thinking: '思考', thinking: '思考',
+1
View File
@@ -3147,6 +3147,7 @@ export const zh: Translations = {
fast: '快速' fast: '快速'
}, },
modelOptions: { modelOptions: {
comboDetails: '模型 / 组合详情',
noOptions: '此模型没有可用选项', noOptions: '此模型没有可用选项',
options: '选项', options: '选项',
thinking: '思考', 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
}
}
+1 -1
View File
@@ -65,7 +65,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "aiturk-ide", "name": "aiturk-ide",
"version": "1.0.0-beta.7", "version": "1.0.0-beta.10",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@assistant-ui/core": "0.2.23", "@assistant-ui/core": "0.2.23",